chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
588
internal/pkg/config/config.go
Normal file
588
internal/pkg/config/config.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/subosito/gotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Language string `yaml:"language"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
DB DBConfig `yaml:"db"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
VectorDB VectorDBConfig `yaml:"vectorDB"`
|
||||
MCP MCPConfig `yaml:"mcp"`
|
||||
OIDC OIDCConfig `yaml:"oidc"`
|
||||
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
|
||||
Identity IdentityConfig `yaml:"identity"`
|
||||
Conversation ConversationConfig `yaml:"conversation"`
|
||||
Webhook WebhookConfig `yaml:"webhook"`
|
||||
Discord DiscordConfig `yaml:"discord"`
|
||||
Email EmailConfig `yaml:"email"`
|
||||
Slack SlackConfig `yaml:"slack"`
|
||||
}
|
||||
|
||||
func (c Config) LanguageOrDefault() string {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Language)) {
|
||||
case "zh", "zh-cn", "zh_cn", "zh-hans":
|
||||
return "zh-CN"
|
||||
case "en", "en-us", "en_us":
|
||||
return "en-US"
|
||||
case "ru", "ru-ru", "ru_ru":
|
||||
return "ru-RU"
|
||||
default:
|
||||
return "en-US"
|
||||
}
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
CompanyName string `yaml:"companyName"`
|
||||
CompanyLogoURL string `yaml:"companyLogoUrl"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
// TrustedProxies are the CIDR blocks of the reverse proxies that sit in front
|
||||
// of the application. Gin's own default is 0.0.0.0/0 and ::/0, which trusts
|
||||
// every peer and makes ClientIP() return the leftmost X-Forwarded-For value -
|
||||
// a header any caller can set.
|
||||
TrustedProxies []string `yaml:"trustedProxies"`
|
||||
// TrustedPlatform names an edge that overwrites rather than appends the real
|
||||
// client address, for example "cloudflare". When set it takes precedence over
|
||||
// X-Forwarded-For entirely.
|
||||
TrustedPlatform string `yaml:"trustedPlatform"`
|
||||
RateLimit RateLimitConfig `yaml:"rateLimit"`
|
||||
}
|
||||
|
||||
// defaultTrustedProxies covers loopback, RFC1918, IPv6 unique-local and
|
||||
// link-local ranges. That is the shape of almost every real deployment - a
|
||||
// sidecar tunnel, a compose network, a local nginx - and a client on the public
|
||||
// internet cannot present one of these addresses as its direct peer, so
|
||||
// X-Forwarded-For stays honest. When the app is exposed directly the peer is a
|
||||
// public address, is not trusted, and Gin falls back to it.
|
||||
var defaultTrustedProxies = []string{
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
}
|
||||
|
||||
func (s ServerConfig) TrustedProxiesOrDefault() []string {
|
||||
proxies := make([]string, 0, len(s.TrustedProxies))
|
||||
for _, proxy := range s.TrustedProxies {
|
||||
if proxy = strings.TrimSpace(proxy); proxy != "" {
|
||||
proxies = append(proxies, proxy)
|
||||
}
|
||||
}
|
||||
if len(proxies) == 0 {
|
||||
return defaultTrustedProxies
|
||||
}
|
||||
return proxies
|
||||
}
|
||||
|
||||
// TrustedPlatformHeader resolves the configured platform name to the header Gin
|
||||
// should read the client address from. Recognised names map to Gin's own
|
||||
// constants; any other non-empty value is passed through as a literal header
|
||||
// name, which is what Gin's TrustedPlatform field expects.
|
||||
func (s ServerConfig) TrustedPlatformHeader() string {
|
||||
platform := strings.TrimSpace(s.TrustedPlatform)
|
||||
if platform == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(platform) {
|
||||
case "cloudflare", "cf":
|
||||
return "CF-Connecting-IP"
|
||||
case "fly.io", "flyio", "fly-io":
|
||||
return "Fly-Client-IP"
|
||||
case "google-app-engine", "appengine", "gae":
|
||||
return "X-Appengine-Remote-Addr"
|
||||
default:
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
func (s ServerConfig) Address() string {
|
||||
if s.Port <= 0 {
|
||||
return ":8080"
|
||||
}
|
||||
return fmt.Sprintf(":%d", s.Port)
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
// AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。
|
||||
// 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。
|
||||
AllowedOrigins []string `yaml:"allowedOrigins"`
|
||||
}
|
||||
|
||||
// RateLimitConfig bounds how often one client address may call the public,
|
||||
// unauthenticated endpoints. It deliberately does not cover channel webhooks,
|
||||
// websockets or authenticated dashboard routes: a platform that receives a 429
|
||||
// from a webhook endpoint stops retrying and eventually disables the webhook.
|
||||
type RateLimitConfig struct {
|
||||
// Enabled defaults to true. Set it to false to switch the limits off without
|
||||
// taking them out of the route table.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
// WindowSeconds is the length of the counting window. Zero or negative means
|
||||
// one minute.
|
||||
WindowSeconds int `yaml:"windowSeconds"`
|
||||
}
|
||||
|
||||
func (r RateLimitConfig) IsEnabled() bool {
|
||||
if r.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *r.Enabled
|
||||
}
|
||||
|
||||
func (r RateLimitConfig) WindowSecondsOrDefault() int {
|
||||
if r.WindowSeconds <= 0 {
|
||||
return 60
|
||||
}
|
||||
return r.WindowSeconds
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
DSN string `yaml:"dsn"`
|
||||
MaxIdleConns int `yaml:"maxIdleConns"`
|
||||
MaxOpenConns int `yaml:"maxOpenConns"`
|
||||
ConnMaxIdleTimeSeconds int `yaml:"connMaxIdleTimeSeconds"`
|
||||
ConnMaxLifetimeSeconds int `yaml:"connMaxLifetimeSeconds"`
|
||||
}
|
||||
|
||||
type LoggerConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
AddSource bool `yaml:"addSource"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
PasswordLoginEnabled *bool `yaml:"passwordLoginEnabled"`
|
||||
TokenTTLHours int `yaml:"tokenTTLHours"`
|
||||
MaxFailedAttempts int `yaml:"maxFailedAttempts"`
|
||||
// MaxFailedAttemptsPerIP bounds failures from one client address across every
|
||||
// username, which is what credential stuffing looks like. Zero or unset
|
||||
// derives four times MaxFailedAttempts; it is disabled when MaxFailedAttempts
|
||||
// is disabled.
|
||||
MaxFailedAttemptsPerIP int `yaml:"maxFailedAttemptsPerIP"`
|
||||
CredentialLockMinute int `yaml:"credentialLockMinute"`
|
||||
}
|
||||
|
||||
// MaxFailedAttemptsPerIPOrDefault derives the per-address threshold from the
|
||||
// per-account one so that a deployment which only tunes MaxFailedAttempts still
|
||||
// gets a coherent pair of limits.
|
||||
func (a AuthConfig) MaxFailedAttemptsPerIPOrDefault() int {
|
||||
if a.MaxFailedAttemptsPerIP > 0 {
|
||||
return a.MaxFailedAttemptsPerIP
|
||||
}
|
||||
if a.MaxFailedAttempts <= 0 {
|
||||
return 0
|
||||
}
|
||||
return a.MaxFailedAttempts * 4
|
||||
}
|
||||
|
||||
func (a AuthConfig) IsPasswordLoginEnabled() bool {
|
||||
if a.PasswordLoginEnabled == nil {
|
||||
return true
|
||||
}
|
||||
return *a.PasswordLoginEnabled
|
||||
}
|
||||
|
||||
type CustomerSessionConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
TTLMinutes int `yaml:"ttlMinutes"`
|
||||
RefreshThresholdMinutes int `yaml:"refreshThresholdMinutes"`
|
||||
}
|
||||
|
||||
// IdentityConfig holds the deployment-wide shared secret a host platform uses to
|
||||
// sign the external visitor ID it passes to the widget.
|
||||
type IdentityConfig struct {
|
||||
// Secret is the HMAC-SHA256 key. Leave it empty to disable the feature: a
|
||||
// visitor whose ID carries no valid signature is then always treated as an
|
||||
// anonymous guest, which is the safe default.
|
||||
Secret string `yaml:"secret"`
|
||||
// MaxAgeMinutes bounds how old a signed identity may be. It limits how long a
|
||||
// captured signature stays replayable. Values <= 0 fall back to 24 hours.
|
||||
MaxAgeMinutes int `yaml:"maxAgeMinutes"`
|
||||
}
|
||||
|
||||
func (c IdentityConfig) MaxAge() time.Duration {
|
||||
if c.MaxAgeMinutes <= 0 {
|
||||
return 24 * time.Hour
|
||||
}
|
||||
return time.Duration(c.MaxAgeMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// ConversationConfig holds visitor-facing conversation limits.
|
||||
type ConversationConfig struct {
|
||||
// CustomerMaxOpen caps how many not-yet-closed conversations one visitor may
|
||||
// have at a time. It exists to protect agents, not the visitor: every open
|
||||
// conversation is real work, and in human-only mode opening one dispatches
|
||||
// an agent immediately. Without a cap a single caller could flood the queue,
|
||||
// and the visitor cannot undo that by closing a conversation.
|
||||
//
|
||||
// 0 or unset falls back to the default of 3. Use -1 to remove the cap
|
||||
// entirely, which is an explicit opt-out rather than something reached by
|
||||
// omission.
|
||||
CustomerMaxOpen int `yaml:"customerMaxOpen"`
|
||||
// EmailChatLiveMinutes is how long a chat still counts as being watched. An
|
||||
// email that arrives while a chat is live is answered in the chat instead of
|
||||
// starting an email thread, because the visitor is present there and absent
|
||||
// from their inbox. Past this window the chat is treated as abandoned and the
|
||||
// email becomes the conversation.
|
||||
EmailChatLiveMinutes int `yaml:"emailChatLiveMinutes"`
|
||||
}
|
||||
|
||||
func (c ConversationConfig) EmailChatLiveWindow() time.Duration {
|
||||
if c.EmailChatLiveMinutes <= 0 {
|
||||
return 30 * time.Minute
|
||||
}
|
||||
return time.Duration(c.EmailChatLiveMinutes) * time.Minute
|
||||
}
|
||||
|
||||
func (c ConversationConfig) MaxOpen() int {
|
||||
if c.CustomerMaxOpen < 0 {
|
||||
return 0
|
||||
}
|
||||
if c.CustomerMaxOpen == 0 {
|
||||
return 3
|
||||
}
|
||||
return c.CustomerMaxOpen
|
||||
}
|
||||
|
||||
func (c CustomerSessionConfig) TTL() int {
|
||||
if c.TTLMinutes <= 0 {
|
||||
return 120
|
||||
}
|
||||
return c.TTLMinutes
|
||||
}
|
||||
|
||||
func (c CustomerSessionConfig) RefreshThreshold() int {
|
||||
if c.RefreshThresholdMinutes <= 0 {
|
||||
return 30
|
||||
}
|
||||
return c.RefreshThresholdMinutes
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Default enums.AssetProvider `yaml:"default"`
|
||||
MaxUploadSizeMB int64 `yaml:"maxUploadSizeMB"`
|
||||
Local LocalStorageConfig `yaml:"local"`
|
||||
OSS OSSStorageConfig `yaml:"oss"`
|
||||
}
|
||||
|
||||
func (s StorageConfig) MaxUploadSizeBytes() int64 {
|
||||
if s.MaxUploadSizeMB <= 0 {
|
||||
return 5 << 20
|
||||
}
|
||||
return s.MaxUploadSizeMB << 20
|
||||
}
|
||||
|
||||
func (s StorageConfig) MaxRequestBodySizeBytes() int64 {
|
||||
limit := s.MaxUploadSizeBytes()
|
||||
return limit + (1 << 20)
|
||||
}
|
||||
|
||||
type LocalStorageConfig struct {
|
||||
Root string `yaml:"root"`
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
}
|
||||
|
||||
type OSSStorageConfig struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
AccessKeyID string `yaml:"accessKeyId"`
|
||||
AccessKeySecret string `yaml:"accessKeySecret"`
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
Private bool `yaml:"private"`
|
||||
SignedURLExpire int `yaml:"signedUrlExpireSeconds"`
|
||||
}
|
||||
|
||||
type VectorDBConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
Qdrant QdrantVectorDBConfig `yaml:"qdrant"`
|
||||
LanceDB LanceDBVectorDBConfig `yaml:"lancedb"`
|
||||
}
|
||||
|
||||
type QdrantVectorDBConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
GrpcPort int `yaml:"grpcPort"`
|
||||
APIKey string `yaml:"apiKey"`
|
||||
UseTLS bool `yaml:"useTls"`
|
||||
}
|
||||
|
||||
type LanceDBVectorDBConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Servers map[string]MCPServerConfig `yaml:"servers"`
|
||||
}
|
||||
|
||||
type MCPServerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
TimeoutMS int `yaml:"timeoutMs"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
ClientID string `yaml:"clientId"`
|
||||
ClientSecret string `yaml:"clientSecret"`
|
||||
RedirectURL string `yaml:"redirectUrl"`
|
||||
StateSecret string `yaml:"stateSecret"`
|
||||
Scopes []string `yaml:"scopes"`
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
OrgSyncSecret string `yaml:"orgSyncSecret"`
|
||||
DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"`
|
||||
}
|
||||
|
||||
// DiscordConfig holds deployment-wide Discord bot credentials. A channel may
|
||||
// carry its own bot token, which takes precedence; these are the fallback for a
|
||||
// single shared bot.
|
||||
type DiscordConfig struct {
|
||||
ClientID string `yaml:"clientId"`
|
||||
ClientSecret string `yaml:"clientSecret"`
|
||||
BotToken string `yaml:"botToken"`
|
||||
PublicKey string `yaml:"publicKey"`
|
||||
}
|
||||
|
||||
// EmailConfig holds the email delivery provider settings.
|
||||
type EmailConfig struct {
|
||||
Provider string `yaml:"provider"`
|
||||
FromAddress string `yaml:"fromAddress"`
|
||||
FromName string `yaml:"fromName"`
|
||||
APIKey string `yaml:"apiKey"`
|
||||
SMTPHost string `yaml:"smtpHost"`
|
||||
SMTPPort int `yaml:"smtpPort"`
|
||||
SMTPUser string `yaml:"smtpUser"`
|
||||
SMTPPassword string `yaml:"smtpPassword"`
|
||||
SMTPUseTLS bool `yaml:"smtpUseTls"`
|
||||
// SMTPAllowInsecure accepts a self-signed certificate and skips opportunistic
|
||||
// STARTTLS so a deployment can be pointed at a local test SMTP server. It
|
||||
// weakens transport security and must stay off for real mailboxes.
|
||||
SMTPAllowInsecure bool `yaml:"smtpAllowInsecure"`
|
||||
InboundSecret string `yaml:"inboundSecret"`
|
||||
}
|
||||
|
||||
// SlackConfig holds deployment-wide Slack app credentials. A channel may carry
|
||||
// its own bot token and signing secret, which take precedence; these are the
|
||||
// fallback for a single shared Slack app.
|
||||
type SlackConfig struct {
|
||||
ClientID string `yaml:"clientId"`
|
||||
ClientSecret string `yaml:"clientSecret"`
|
||||
BotToken string `yaml:"botToken"`
|
||||
SigningSecret string `yaml:"signingSecret"`
|
||||
}
|
||||
|
||||
// SlackApp resolves the Slack app credentials for one channel. A channel value
|
||||
// wins over the deployment-wide one, so a single deployment can serve several
|
||||
// workspaces while still having a default app.
|
||||
func (c Config) SlackApp(channelBotToken, channelSigningSecret string) SlackConfig {
|
||||
return SlackConfig{
|
||||
ClientID: strings.TrimSpace(c.Slack.ClientID),
|
||||
ClientSecret: strings.TrimSpace(c.Slack.ClientSecret),
|
||||
BotToken: firstNonBlank(channelBotToken, c.Slack.BotToken),
|
||||
SigningSecret: firstNonBlank(channelSigningSecret, c.Slack.SigningSecret),
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
loadDotEnv(path)
|
||||
|
||||
v := viper.New()
|
||||
bindConfigDefaults(v)
|
||||
|
||||
if strings.TrimSpace(path) != "" {
|
||||
v.SetConfigFile(path)
|
||||
v.SetConfigType("yaml")
|
||||
}
|
||||
|
||||
v.SetEnvPrefix("AGENT_DESK")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
bindEnvironmentAliases(v)
|
||||
|
||||
if strings.TrimSpace(path) != "" {
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var configFileNotFoundError viper.ConfigFileNotFoundError
|
||||
if !os.IsNotExist(err) && !errors.As(err, &configFileNotFoundError) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := v.Unmarshal(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeLoadedConfig(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadDotEnv(configPath string) {
|
||||
if envFile := os.Getenv("AGENT_DESK_ENV_FILE"); envFile != "" {
|
||||
_ = gotenv.Load(envFile)
|
||||
return
|
||||
}
|
||||
if envFile := os.Getenv("ENV_FILE"); envFile != "" {
|
||||
_ = gotenv.Load(envFile)
|
||||
return
|
||||
}
|
||||
_ = gotenv.Load(".env")
|
||||
_ = gotenv.Load("../.env")
|
||||
_ = gotenv.Load("../../.env")
|
||||
if configPath != "" {
|
||||
dir := filepath.Dir(configPath)
|
||||
if dir != "." && dir != "" {
|
||||
_ = gotenv.Load(filepath.Join(dir, ".env"))
|
||||
_ = gotenv.Load(filepath.Join(dir, "..", ".env"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindConfigDefaults(v *viper.Viper) {
|
||||
v.SetDefault("language", "zh-CN")
|
||||
v.SetDefault("server.port", 8083)
|
||||
v.SetDefault("server.companyName", "")
|
||||
v.SetDefault("server.companyLogoUrl", "")
|
||||
v.SetDefault("server.cors.allowedOrigins", []string{})
|
||||
v.SetDefault("server.trustedProxies", []string{})
|
||||
v.SetDefault("server.trustedPlatform", "")
|
||||
v.SetDefault("server.rateLimit.windowSeconds", 60)
|
||||
v.SetDefault("db.type", "sqlite")
|
||||
v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000")
|
||||
v.SetDefault("db.maxIdleConns", 5)
|
||||
v.SetDefault("db.maxOpenConns", 20)
|
||||
v.SetDefault("db.connMaxIdleTimeSeconds", 300)
|
||||
v.SetDefault("db.connMaxLifetimeSeconds", 1800)
|
||||
v.SetDefault("logger.level", "info")
|
||||
v.SetDefault("logger.format", "text")
|
||||
v.SetDefault("logger.addSource", false)
|
||||
v.SetDefault("auth.tokenTTLHours", 12)
|
||||
v.SetDefault("auth.maxFailedAttempts", 5)
|
||||
v.SetDefault("auth.maxFailedAttemptsPerIP", 0)
|
||||
v.SetDefault("auth.credentialLockMinute", 15)
|
||||
v.SetDefault("customerSession.ttlMinutes", 120)
|
||||
v.SetDefault("customerSession.refreshThresholdMinutes", 30)
|
||||
// Intentionally no default: an empty identity secret disables signed visitor
|
||||
// identities rather than silently trusting unsigned ones.
|
||||
v.SetDefault("identity.secret", "")
|
||||
v.SetDefault("identity.maxAgeMinutes", 1440)
|
||||
v.SetDefault("storage.default", "local")
|
||||
v.SetDefault("storage.maxUploadSizeMB", 20)
|
||||
v.SetDefault("storage.local.root", "data/storage")
|
||||
v.SetDefault("storage.local.baseUrl", "/storage")
|
||||
v.SetDefault("vectorDB.type", "qdrant")
|
||||
v.SetDefault("vectorDB.qdrant.host", "127.0.0.1")
|
||||
v.SetDefault("vectorDB.qdrant.grpcPort", 6334)
|
||||
v.SetDefault("mcp.enabled", true)
|
||||
v.SetDefault("discord.clientId", "")
|
||||
v.SetDefault("discord.clientSecret", "")
|
||||
v.SetDefault("discord.botToken", "")
|
||||
v.SetDefault("discord.publicKey", "")
|
||||
v.SetDefault("slack.clientId", "")
|
||||
v.SetDefault("slack.clientSecret", "")
|
||||
v.SetDefault("slack.botToken", "")
|
||||
v.SetDefault("slack.signingSecret", "")
|
||||
v.SetDefault("email.provider", "smtp")
|
||||
v.SetDefault("email.fromAddress", "")
|
||||
v.SetDefault("email.fromName", "")
|
||||
v.SetDefault("email.apiKey", "")
|
||||
v.SetDefault("email.smtpHost", "")
|
||||
v.SetDefault("email.smtpPort", 587)
|
||||
v.SetDefault("email.smtpUser", "")
|
||||
v.SetDefault("email.smtpPassword", "")
|
||||
v.SetDefault("email.smtpUseTls", false)
|
||||
v.SetDefault("email.inboundSecret", "")
|
||||
}
|
||||
|
||||
func bindEnvironmentAliases(v *viper.Viper) {
|
||||
// Prefixed AGENT_DESK_* aliases are listed first so that ambient legacy
|
||||
// variables (PORT, DATABASE_URL, ...) cannot silently override the
|
||||
// documented configuration.
|
||||
_ = v.BindEnv("server.port", "AGENT_DESK_SERVER_PORT", "PORT", "SERVER_PORT")
|
||||
_ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME")
|
||||
_ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL")
|
||||
_ = v.BindEnv("server.trustedProxies", "AGENT_DESK_SERVER_TRUSTEDPROXIES", "TRUSTED_PROXIES")
|
||||
_ = v.BindEnv("server.trustedPlatform", "AGENT_DESK_SERVER_TRUSTEDPLATFORM", "TRUSTED_PLATFORM")
|
||||
_ = v.BindEnv("server.rateLimit.enabled", "AGENT_DESK_SERVER_RATELIMIT_ENABLED", "RATE_LIMIT_ENABLED")
|
||||
_ = v.BindEnv("server.rateLimit.windowSeconds", "AGENT_DESK_SERVER_RATELIMIT_WINDOWSECONDS", "RATE_LIMIT_WINDOW_SECONDS")
|
||||
_ = v.BindEnv("db.type", "AGENT_DESK_DB_TYPE", "DATABASE_TYPE", "DB_TYPE")
|
||||
_ = v.BindEnv("db.dsn", "AGENT_DESK_DB_DSN", "DATABASE_URL", "DB_DSN")
|
||||
_ = v.BindEnv("auth.passwordLoginEnabled", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED", "PASSWORD_LOGIN_ENABLED")
|
||||
_ = v.BindEnv("auth.tokenTTLHours", "AGENT_DESK_AUTH_TOKENTTLHOURS", "AUTH_TOKEN_TTL_HOURS")
|
||||
_ = v.BindEnv("customerSession.secret", "AGENT_DESK_CUSTOMERSESSION_SECRET", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET")
|
||||
_ = v.BindEnv("identity.secret", "AGENT_DESK_IDENTITY_SECRET", "AGENT_DESK_IDENTITY_HMAC_SECRET", "IDENTITY_HMAC_SECRET", "WIDGET_IDENTITY_SECRET")
|
||||
_ = v.BindEnv("identity.maxAgeMinutes", "AGENT_DESK_IDENTITY_MAXAGEMINUTES", "IDENTITY_SIGNATURE_MAX_AGE_MINUTES")
|
||||
_ = v.BindEnv("conversation.customerMaxOpen", "AGENT_DESK_CONVERSATION_CUSTOMERMAXOPEN", "CONVERSATION_CUSTOMER_MAX_OPEN")
|
||||
_ = v.BindEnv("conversation.emailChatLiveMinutes", "AGENT_DESK_CONVERSATION_EMAILCHATLIVEMINUTES", "CONVERSATION_EMAIL_CHAT_LIVE_MINUTES")
|
||||
_ = v.BindEnv("storage.default", "AGENT_DESK_STORAGE_DEFAULT", "STORAGE_DEFAULT", "STORAGE_TYPE")
|
||||
_ = v.BindEnv("storage.local.root", "AGENT_DESK_STORAGE_LOCAL_ROOT", "STORAGE_LOCAL_ROOT")
|
||||
_ = v.BindEnv("storage.local.baseUrl", "AGENT_DESK_STORAGE_LOCAL_BASEURL", "STORAGE_LOCAL_BASE_URL")
|
||||
_ = v.BindEnv("vectorDB.type", "AGENT_DESK_VECTORDB_TYPE", "VECTOR_DB_TYPE")
|
||||
_ = v.BindEnv("vectorDB.qdrant.host", "AGENT_DESK_VECTORDB_QDRANT_HOST", "QDRANT_HOST")
|
||||
_ = v.BindEnv("vectorDB.qdrant.grpcPort", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT", "QDRANT_GRPC_PORT", "QDRANT_PORT")
|
||||
_ = v.BindEnv("vectorDB.qdrant.apiKey", "AGENT_DESK_VECTORDB_QDRANT_APIKEY", "QDRANT_API_KEY")
|
||||
_ = v.BindEnv("oidc.enabled", "AGENT_DESK_OIDC_ENABLED", "OIDC_ENABLED")
|
||||
_ = v.BindEnv("oidc.issuer", "AGENT_DESK_OIDC_ISSUER", "OIDC_ISSUER")
|
||||
_ = v.BindEnv("oidc.clientId", "AGENT_DESK_OIDC_CLIENTID", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID")
|
||||
_ = v.BindEnv("oidc.clientSecret", "AGENT_DESK_OIDC_CLIENTSECRET", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET")
|
||||
_ = v.BindEnv("oidc.redirectUrl", "AGENT_DESK_OIDC_REDIRECTURL", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI")
|
||||
_ = v.BindEnv("webhook.orgSyncSecret", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET", "ORG_SYNC_SECRET", "WEBHOOK_SECRET")
|
||||
_ = v.BindEnv("discord.clientId", "AGENT_DESK_DISCORD_CLIENTID", "DISCORD_CLIENT_ID")
|
||||
_ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET")
|
||||
_ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN")
|
||||
_ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY")
|
||||
_ = v.BindEnv("slack.clientId", "AGENT_DESK_SLACK_CLIENTID", "SLACK_CLIENT_ID")
|
||||
_ = v.BindEnv("slack.clientSecret", "AGENT_DESK_SLACK_CLIENTSECRET", "SLACK_CLIENT_SECRET")
|
||||
_ = v.BindEnv("slack.botToken", "AGENT_DESK_SLACK_BOTTOKEN", "SLACK_BOT_TOKEN")
|
||||
_ = v.BindEnv("slack.signingSecret", "AGENT_DESK_SLACK_SIGNINGSECRET", "SLACK_SIGNING_SECRET")
|
||||
_ = v.BindEnv("email.provider", "EMAIL_PROVIDER", "AGENT_DESK_EMAIL_PROVIDER")
|
||||
_ = v.BindEnv("email.fromAddress", "EMAIL_FROM", "EMAIL_FROM_ADDRESS", "AGENT_DESK_EMAIL_FROMADDRESS")
|
||||
_ = v.BindEnv("email.fromName", "EMAIL_FROM_NAME", "EMAIL_SENDER_NAME", "AGENT_DESK_EMAIL_FROMNAME")
|
||||
_ = v.BindEnv("email.apiKey", "EMAIL_API_KEY", "BREVO_API_KEY", "AGENT_DESK_EMAIL_APIKEY")
|
||||
_ = v.BindEnv("email.smtpHost", "SMTP_HOST", "EMAIL_SMTP_HOST", "AGENT_DESK_EMAIL_SMTPHOST")
|
||||
_ = v.BindEnv("email.smtpPort", "SMTP_PORT", "EMAIL_SMTP_PORT", "AGENT_DESK_EMAIL_SMTPPORT")
|
||||
_ = v.BindEnv("email.smtpUser", "SMTP_USER", "EMAIL_SMTP_USER", "AGENT_DESK_EMAIL_SMTPUSER")
|
||||
_ = v.BindEnv("email.smtpPassword", "SMTP_PASSWORD", "SMTP_PASS", "AGENT_DESK_EMAIL_SMTPPASSWORD")
|
||||
_ = v.BindEnv("email.smtpUseTls", "SMTP_USE_TLS", "SMTP_SSL", "AGENT_DESK_EMAIL_SMTPUSETLS")
|
||||
_ = v.BindEnv("email.smtpAllowInsecure", "SMTP_ALLOW_INSECURE", "AGENT_DESK_EMAIL_SMTPALLOWINSECURE")
|
||||
_ = v.BindEnv("email.inboundSecret", "EMAIL_INBOUND_SECRET", "EMAIL_WEBHOOK_SECRET", "AGENT_DESK_EMAIL_INBOUNDSECRET")
|
||||
}
|
||||
|
||||
func normalizeLoadedConfig(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.DB.Type == "sqlite" && (strings.HasPrefix(cfg.DB.DSN, "postgres://") || strings.HasPrefix(cfg.DB.DSN, "postgresql://")) {
|
||||
cfg.DB.Type = "postgres"
|
||||
} else if cfg.DB.Type == "sqlite" && strings.Contains(cfg.DB.DSN, "@tcp(") {
|
||||
cfg.DB.Type = "mysql"
|
||||
}
|
||||
}
|
||||
313
internal/pkg/config/config_test.go
Normal file
313
internal/pkg/config/config_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReadsCORSAllowedOrigins(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := []byte(`server:
|
||||
port: 8083
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- https://console.example.com
|
||||
- http://localhost:3000
|
||||
`)
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
got := cfg.Server.CORS.AllowedOrigins
|
||||
want := []string{"https://console.example.com", "http://localhost:3000"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(AllowedOrigins)=%d want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AllowedOrigins[%d]=%q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverridesValuesFromEnvironment(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := []byte(`server:
|
||||
port: 8083
|
||||
db:
|
||||
type: sqlite
|
||||
dsn: file:./data/app.db?_busy_timeout=5000
|
||||
storage:
|
||||
local:
|
||||
baseUrl: /storage
|
||||
mcp:
|
||||
servers:
|
||||
system:
|
||||
endpoint: http://127.0.0.1:8083/api/mcp
|
||||
`)
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
t.Setenv("AGENT_DESK_SERVER_PORT", "8090")
|
||||
t.Setenv("AGENT_DESK_DB_DSN", "mysql-dsn")
|
||||
t.Setenv("AGENT_DESK_STORAGE_LOCAL_BASEURL", "/files")
|
||||
t.Setenv("AGENT_DESK_MCP_SERVERS_SYSTEM_ENDPOINT", "http://127.0.0.1:8090/api/mcp")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 8090 {
|
||||
t.Fatalf("Server.Port=%d want 8090", cfg.Server.Port)
|
||||
}
|
||||
if cfg.DB.Type != "sqlite" {
|
||||
t.Fatalf("DB.Type=%q want sqlite", cfg.DB.Type)
|
||||
}
|
||||
if cfg.DB.DSN != "mysql-dsn" {
|
||||
t.Fatalf("DB.DSN=%q want mysql-dsn", cfg.DB.DSN)
|
||||
}
|
||||
if cfg.Storage.Local.BaseURL != "/files" {
|
||||
t.Fatalf("Storage.Local.BaseURL=%q want /files", cfg.Storage.Local.BaseURL)
|
||||
}
|
||||
if cfg.MCP.Servers["system"].Endpoint != "http://127.0.0.1:8090/api/mcp" {
|
||||
t.Fatalf("MCP system endpoint=%q", cfg.MCP.Servers["system"].Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
envPath := filepath.Join(tempDir, ".env")
|
||||
envContent := []byte(`PORT=9090
|
||||
COMPANY_NAME=CustomDesk
|
||||
COMPANY_LOGO_URL=/custom-logo.svg
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable
|
||||
PASSWORD_LOGIN_ENABLED=false
|
||||
JWT_SECRET=super-secret-key-12345
|
||||
QDRANT_HOST=10.0.0.5
|
||||
QDRANT_PORT=6334
|
||||
OIDC_ENABLED=true
|
||||
OIDC_ISSUER=https://auth.example.com
|
||||
OIDC_CLIENT_ID=client-123
|
||||
OIDC_CLIENT_SECRET=secret-456
|
||||
OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback
|
||||
ORG_SYNC_SECRET=webhook-secret-789
|
||||
`)
|
||||
if err := os.WriteFile(envPath, envContent, 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("ENV_FILE", envPath)
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.CompanyName != "CustomDesk" {
|
||||
t.Fatalf("Server.CompanyName=%q want CustomDesk", cfg.Server.CompanyName)
|
||||
}
|
||||
if cfg.Server.CompanyLogoURL != "/custom-logo.svg" {
|
||||
t.Fatalf("Server.CompanyLogoURL=%q want /custom-logo.svg", cfg.Server.CompanyLogoURL)
|
||||
}
|
||||
if cfg.DB.Type != "postgres" {
|
||||
t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type)
|
||||
}
|
||||
if cfg.DB.DSN != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" {
|
||||
t.Fatalf("DB.DSN=%q", cfg.DB.DSN)
|
||||
}
|
||||
if cfg.Auth.IsPasswordLoginEnabled() {
|
||||
t.Fatalf("expected PasswordLoginEnabled to be false")
|
||||
}
|
||||
if cfg.CustomerSession.Secret != "super-secret-key-12345" {
|
||||
t.Fatalf("CustomerSession.Secret=%q", cfg.CustomerSession.Secret)
|
||||
}
|
||||
if cfg.VectorDB.Qdrant.Host != "10.0.0.5" {
|
||||
t.Fatalf("Qdrant.Host=%q", cfg.VectorDB.Qdrant.Host)
|
||||
}
|
||||
if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
|
||||
t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort)
|
||||
}
|
||||
if !cfg.OIDC.Enabled {
|
||||
t.Fatalf("expected OIDC.Enabled=true")
|
||||
}
|
||||
if cfg.OIDC.Issuer != "https://auth.example.com" {
|
||||
t.Fatalf("OIDC.Issuer=%q", cfg.OIDC.Issuer)
|
||||
}
|
||||
if cfg.OIDC.ClientID != "client-123" {
|
||||
t.Fatalf("OIDC.ClientID=%q", cfg.OIDC.ClientID)
|
||||
}
|
||||
if cfg.OIDC.ClientSecret != "secret-456" {
|
||||
t.Fatalf("OIDC.ClientSecret=%q", cfg.OIDC.ClientSecret)
|
||||
}
|
||||
if cfg.OIDC.RedirectURL != "https://desk.example.com/api/auth/oidc_callback" {
|
||||
t.Fatalf("OIDC.RedirectURL=%q", cfg.OIDC.RedirectURL)
|
||||
}
|
||||
if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" {
|
||||
t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigMaxFailedAttemptsPerIPOrDefault(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg AuthConfig
|
||||
want int
|
||||
}{
|
||||
{"explicit value wins", AuthConfig{MaxFailedAttempts: 5, MaxFailedAttemptsPerIP: 30}, 30},
|
||||
{"unset derives four times the per-account limit", AuthConfig{MaxFailedAttempts: 5}, 20},
|
||||
{"disabled per-account limit disables both", AuthConfig{MaxFailedAttempts: 0}, 0},
|
||||
{"negative per-account limit disables both", AuthConfig{MaxFailedAttempts: -1}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.cfg.MaxFailedAttemptsPerIPOrDefault(); got != tc.want {
|
||||
t.Errorf("%s: MaxFailedAttemptsPerIPOrDefault() = %d want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigTrustedProxiesOrDefault(t *testing.T) {
|
||||
// An unset list must not fall through to Gin's own default of 0.0.0.0/0 and
|
||||
// ::/0, which trusts every peer and makes X-Forwarded-For authoritative.
|
||||
got := ServerConfig{}.TrustedProxiesOrDefault()
|
||||
for _, want := range []string{"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"} {
|
||||
found := false
|
||||
for _, entry := range got {
|
||||
if entry == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("TrustedProxiesOrDefault() = %v, missing %s", got, want)
|
||||
}
|
||||
}
|
||||
for _, entry := range got {
|
||||
if entry == "0.0.0.0/0" || entry == "::/0" {
|
||||
t.Errorf("TrustedProxiesOrDefault() includes %s, which trusts every peer", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Blank entries from a comma-separated environment variable must not become
|
||||
// empty CIDRs, which Gin would reject at startup.
|
||||
got = ServerConfig{TrustedProxies: []string{" 10.1.0.0/16 ", "", " "}}.TrustedProxiesOrDefault()
|
||||
if len(got) != 1 || got[0] != "10.1.0.0/16" {
|
||||
t.Errorf("TrustedProxiesOrDefault() = %v want [10.1.0.0/16]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigTrustedPlatformHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
platform string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"cloudflare", "CF-Connecting-IP"},
|
||||
{"Cloudflare", "CF-Connecting-IP"},
|
||||
{" CF ", "CF-Connecting-IP"},
|
||||
{"fly.io", "Fly-Client-IP"},
|
||||
{"google-app-engine", "X-Appengine-Remote-Addr"},
|
||||
// Anything unrecognised is a literal header name, which is what Gin's
|
||||
// TrustedPlatform field expects.
|
||||
{"X-CDN-IP", "X-CDN-IP"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := (ServerConfig{TrustedPlatform: tc.platform}).TrustedPlatformHeader(); got != tc.want {
|
||||
t.Errorf("TrustedPlatformHeader(%q) = %q want %q", tc.platform, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadReadsTrustedProxySettings covers the environment spelling, including
|
||||
// the comma-separated list. Gin rejects an unparseable CIDR at startup, so a
|
||||
// value that arrives as one string instead of a slice would take the process
|
||||
// down rather than degrade quietly.
|
||||
func TestLoadReadsTrustedProxySettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("ENV_FILE", os.DevNull)
|
||||
t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull)
|
||||
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8,172.16.0.0/12")
|
||||
t.Setenv("TRUSTED_PLATFORM", "cloudflare")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{"10.0.0.0/8", "172.16.0.0/12"}
|
||||
got := cfg.Server.TrustedProxies
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("TrustedProxies = %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("TrustedProxies[%d] = %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if cfg.Server.TrustedPlatformHeader() != "CF-Connecting-IP" {
|
||||
t.Errorf("TrustedPlatformHeader() = %q want CF-Connecting-IP", cfg.Server.TrustedPlatformHeader())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitConfigDefaultsToEnabledWithAMinuteWindow(t *testing.T) {
|
||||
var cfg RateLimitConfig
|
||||
if !cfg.IsEnabled() {
|
||||
t.Error("rate limiting must default to enabled; a zero-value config should not silently turn protection off")
|
||||
}
|
||||
if got := cfg.WindowSecondsOrDefault(); got != 60 {
|
||||
t.Errorf("WindowSecondsOrDefault() = %d want 60", got)
|
||||
}
|
||||
|
||||
off := false
|
||||
cfg = RateLimitConfig{Enabled: &off, WindowSeconds: -5}
|
||||
if cfg.IsEnabled() {
|
||||
t.Error("Enabled=false must disable the limits")
|
||||
}
|
||||
if got := cfg.WindowSecondsOrDefault(); got != 60 {
|
||||
t.Errorf("a non-positive window must fall back to 60, got %d", got)
|
||||
}
|
||||
|
||||
on := true
|
||||
cfg = RateLimitConfig{Enabled: &on, WindowSeconds: 30}
|
||||
if !cfg.IsEnabled() {
|
||||
t.Error("Enabled=true must enable the limits")
|
||||
}
|
||||
if got := cfg.WindowSecondsOrDefault(); got != 30 {
|
||||
t.Errorf("WindowSecondsOrDefault() = %d want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsRateLimitSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("ENV_FILE", os.DevNull)
|
||||
t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull)
|
||||
t.Setenv("RATE_LIMIT_ENABLED", "false")
|
||||
t.Setenv("RATE_LIMIT_WINDOW_SECONDS", "30")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Server.RateLimit.IsEnabled() {
|
||||
t.Error("RATE_LIMIT_ENABLED=false did not disable the limits")
|
||||
}
|
||||
if got := cfg.Server.RateLimit.WindowSecondsOrDefault(); got != 30 {
|
||||
t.Errorf("WindowSecondsOrDefault() = %d want 30", got)
|
||||
}
|
||||
}
|
||||
24
internal/pkg/config/conversation_config_test.go
Normal file
24
internal/pkg/config/conversation_config_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConversationMaxOpen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config ConversationConfig
|
||||
want int
|
||||
}{
|
||||
{name: "unset falls back to a cap", config: ConversationConfig{}, want: 3},
|
||||
{name: "explicit zero also means the default", config: ConversationConfig{CustomerMaxOpen: 0}, want: 3},
|
||||
{name: "explicit value wins", config: ConversationConfig{CustomerMaxOpen: 7}, want: 7},
|
||||
{name: "negative removes the cap", config: ConversationConfig{CustomerMaxOpen: -1}, want: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.config.MaxOpen(); got != tt.want {
|
||||
t.Fatalf("MaxOpen() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
internal/pkg/config/language_test.go
Normal file
31
internal/pkg/config/language_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLanguageOrDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "blank falls back", in: "", want: "en-US"},
|
||||
{name: "unsupported falls back", in: "fr-FR", want: "en-US"},
|
||||
{name: "chinese", in: "zh-CN", want: "zh-CN"},
|
||||
{name: "english", in: "en", want: "en-US"},
|
||||
{name: "english underscore", in: "EN_us", want: "en-US"},
|
||||
{name: "russian", in: "ru-RU", want: "ru-RU"},
|
||||
{name: "russian short", in: "ru", want: "ru-RU"},
|
||||
{name: "russian underscore", in: "ru_ru", want: "ru-RU"},
|
||||
{name: "russian padded", in: " ru-RU ", want: "ru-RU"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Config{Language: tt.in}
|
||||
if got := cfg.LanguageOrDefault(); got != tt.want {
|
||||
t.Fatalf("LanguageOrDefault(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
internal/pkg/config/runtime.go
Normal file
32
internal/pkg/config/runtime.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
var current *Config
|
||||
|
||||
func SetCurrent(cfg *Config) {
|
||||
current = cfg
|
||||
}
|
||||
|
||||
func Current() Config {
|
||||
if current == nil {
|
||||
panic("config not initialized")
|
||||
}
|
||||
return *current
|
||||
}
|
||||
|
||||
func GetCurrent() *Config {
|
||||
return current
|
||||
}
|
||||
|
||||
// ResolveSlack resolves the Slack app credentials for one channel without
|
||||
// requiring the caller to nil-check the loaded configuration.
|
||||
func ResolveSlack(channelBotToken, channelSigningSecret string) SlackConfig {
|
||||
if current == nil {
|
||||
return SlackConfig{
|
||||
BotToken: strings.TrimSpace(channelBotToken),
|
||||
SigningSecret: strings.TrimSpace(channelSigningSecret),
|
||||
}
|
||||
}
|
||||
return current.SlackApp(channelBotToken, channelSigningSecret)
|
||||
}
|
||||
44
internal/pkg/config/slack_config_test.go
Normal file
44
internal/pkg/config/slack_config_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlackAppChannelValuesWinOverDeployment(t *testing.T) {
|
||||
cfg := Config{Slack: SlackConfig{
|
||||
ClientID: "deploy-client-id",
|
||||
ClientSecret: "deploy-client-secret",
|
||||
BotToken: "xoxb-deploy",
|
||||
SigningSecret: "deploy-signing-secret",
|
||||
}}
|
||||
|
||||
app := cfg.SlackApp("xoxb-channel", "channel-signing-secret")
|
||||
if app.ClientID != "deploy-client-id" || app.ClientSecret != "deploy-client-secret" {
|
||||
t.Fatalf("the app itself is deployment-wide, got %+v", app)
|
||||
}
|
||||
if app.BotToken != "xoxb-channel" {
|
||||
t.Fatalf("channel bot token must win over the deployment one, got %q", app.BotToken)
|
||||
}
|
||||
if app.SigningSecret != "channel-signing-secret" {
|
||||
t.Fatalf("channel signing secret must win over the deployment one, got %q", app.SigningSecret)
|
||||
}
|
||||
|
||||
fallback := cfg.SlackApp(" ", "")
|
||||
if fallback.BotToken != "xoxb-deploy" || fallback.SigningSecret != "deploy-signing-secret" {
|
||||
t.Fatalf("blank channel values must fall back to the deployment app, got %+v", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveSlack must stay usable before Load has run (tests, standalone
|
||||
// commands): the channel values pass through and nothing panics.
|
||||
func TestResolveSlackWithoutLoadedConfig(t *testing.T) {
|
||||
saved := GetCurrent()
|
||||
defer SetCurrent(saved)
|
||||
|
||||
SetCurrent(nil)
|
||||
app := ResolveSlack("xoxb-channel", "channel-signing-secret")
|
||||
if app.BotToken != "xoxb-channel" || app.SigningSecret != "channel-signing-secret" {
|
||||
t.Fatalf("expected channel values to pass through, got %+v", app)
|
||||
}
|
||||
if app.ClientID != "" || app.ClientSecret != "" {
|
||||
t.Fatalf("no deployment app exists before Load, got %+v", app)
|
||||
}
|
||||
}
|
||||
45
internal/pkg/config/vector_db_config_test.go
Normal file
45
internal/pkg/config/vector_db_config_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestVectorDBConfigUnmarshalNestedProviders(t *testing.T) {
|
||||
raw := []byte(`
|
||||
vectorDB:
|
||||
type: lancedb
|
||||
qdrant:
|
||||
host: 127.0.0.1
|
||||
grpcPort: 6334
|
||||
apiKey: secret
|
||||
useTls: true
|
||||
lancedb:
|
||||
path: data/lancedb
|
||||
`)
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatalf("yaml.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.VectorDB.Type != "lancedb" {
|
||||
t.Fatalf("VectorDB.Type = %q, want %q", cfg.VectorDB.Type, "lancedb")
|
||||
}
|
||||
if cfg.VectorDB.Qdrant.Host != "127.0.0.1" {
|
||||
t.Fatalf("VectorDB.Qdrant.Host = %q, want %q", cfg.VectorDB.Qdrant.Host, "127.0.0.1")
|
||||
}
|
||||
if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
|
||||
t.Fatalf("VectorDB.Qdrant.GrpcPort = %d, want %d", cfg.VectorDB.Qdrant.GrpcPort, 6334)
|
||||
}
|
||||
if cfg.VectorDB.Qdrant.APIKey != "secret" {
|
||||
t.Fatalf("VectorDB.Qdrant.APIKey = %q, want %q", cfg.VectorDB.Qdrant.APIKey, "secret")
|
||||
}
|
||||
if !cfg.VectorDB.Qdrant.UseTLS {
|
||||
t.Fatal("VectorDB.Qdrant.UseTLS = false, want true")
|
||||
}
|
||||
if cfg.VectorDB.LanceDB.Path != "data/lancedb" {
|
||||
t.Fatalf("VectorDB.LanceDB.Path = %q, want %q", cfg.VectorDB.LanceDB.Path, "data/lancedb")
|
||||
}
|
||||
}
|
||||
509
internal/pkg/constants/auth.go
Normal file
509
internal/pkg/constants/auth.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
RoleCodeSuperAdmin = "super_admin" // 超管
|
||||
RoleCodeAdmin = "admin" // 管理员
|
||||
RoleCodeCsTeamLeader = "cs_team_leader" // 客服组长
|
||||
RoleCodeCsUser = "cs_user" // 客服
|
||||
)
|
||||
|
||||
const (
|
||||
AuthTokenPrefix = "ak_"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientTypeAdminWeb = "admin_web"
|
||||
)
|
||||
|
||||
const (
|
||||
BootstrapAdminUsername = "admin"
|
||||
BootstrapAdminPassword = "ChangeMe123!"
|
||||
BootstrapAdminNickname = "Super Admin"
|
||||
)
|
||||
|
||||
// Permission 权限结构体
|
||||
type Permission struct {
|
||||
Name string
|
||||
Code string
|
||||
Type string
|
||||
GroupName string
|
||||
Method string
|
||||
APIPath string
|
||||
SortNo int
|
||||
}
|
||||
|
||||
// 权限常量定义
|
||||
var (
|
||||
// 用户相关权限
|
||||
PermissionUserView = Permission{Name: "查看用户", Code: "user.view", Type: "api", GroupName: "user", Method: "ANY", APIPath: "/api/dashboard/user/list", SortNo: 10}
|
||||
PermissionUserCreate = Permission{Name: "创建用户", Code: "user.create", Type: "api", GroupName: "user", Method: "POST", APIPath: "/api/dashboard/user/create", SortNo: 20}
|
||||
PermissionUserUpdate = Permission{Name: "更新用户", Code: "user.update", Type: "api", GroupName: "user", Method: "POST", APIPath: "/api/dashboard/user/update", SortNo: 30}
|
||||
PermissionUserDelete = Permission{Name: "删除用户", Code: "user.delete", Type: "api", GroupName: "user", Method: "POST", APIPath: "/api/dashboard/user/delete", SortNo: 40}
|
||||
PermissionUserAssignRole = Permission{Name: "分配用户角色", Code: "user.assignRole", Type: "api", GroupName: "user", Method: "POST", APIPath: "/api/dashboard/user/assign_role", SortNo: 50}
|
||||
|
||||
// 角色相关权限
|
||||
PermissionRoleView = Permission{Name: "查看角色", Code: "role.view", Type: "api", GroupName: "role", Method: "ANY", APIPath: "/api/dashboard/role/list", SortNo: 110}
|
||||
PermissionRoleCreate = Permission{Name: "创建角色", Code: "role.create", Type: "api", GroupName: "role", Method: "POST", APIPath: "/api/dashboard/role/create", SortNo: 120}
|
||||
PermissionRoleUpdate = Permission{Name: "更新角色", Code: "role.update", Type: "api", GroupName: "role", Method: "POST", APIPath: "/api/dashboard/role/update", SortNo: 130}
|
||||
PermissionRoleDelete = Permission{Name: "删除角色", Code: "role.delete", Type: "api", GroupName: "role", Method: "POST", APIPath: "/api/dashboard/role/delete", SortNo: 140}
|
||||
PermissionRoleAssignPermission = Permission{Name: "分配角色权限", Code: "role.assignPermission", Type: "api", GroupName: "role", Method: "POST", APIPath: "/api/dashboard/role/assign_permission", SortNo: 150}
|
||||
|
||||
// 权限相关权限
|
||||
PermissionPermissionView = Permission{Name: "查看权限", Code: "permission.view", Type: "api", GroupName: "permission", Method: "ANY", APIPath: "/api/dashboard/permission/list", SortNo: 210}
|
||||
PermissionPermissionSync = Permission{Name: "同步权限", Code: "permission.sync", Type: "api", GroupName: "permission", Method: "POST", APIPath: "/api/dashboard/permission/sync", SortNo: 220}
|
||||
|
||||
// 会话相关权限
|
||||
PermissionSessionView = Permission{Name: "查看会话", Code: "session.view", Type: "api", GroupName: "session", Method: "ANY", APIPath: "/api/dashboard/session/list", SortNo: 310}
|
||||
PermissionSessionRevoke = Permission{Name: "踢除会话", Code: "session.revoke", Type: "api", GroupName: "session", Method: "POST", APIPath: "/api/dashboard/session/revoke", SortNo: 320}
|
||||
|
||||
// 客服会话相关权限
|
||||
PermissionConversationView = Permission{Name: "查看会话", Code: "conversation.view", Type: "api", GroupName: "conversation", Method: "ANY", APIPath: "/api/dashboard/conversation/list", SortNo: 410}
|
||||
PermissionConversationAssign = Permission{Name: "分配会话", Code: "conversation.assign", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/assign", SortNo: 430}
|
||||
PermissionConversationTransfer = Permission{Name: "转接会话", Code: "conversation.transfer", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/transfer", SortNo: 440}
|
||||
PermissionConversationClose = Permission{Name: "关闭会话", Code: "conversation.close", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/close", SortNo: 450}
|
||||
PermissionConversationSend = Permission{Name: "发送会话消息", Code: "conversation.send", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/send_message", SortNo: 460}
|
||||
PermissionConversationTag = Permission{Name: "管理会话标签", Code: "conversation.tag", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/add_tag", SortNo: 470}
|
||||
PermissionConversationHandover = Permission{Name: "处理会话交接", Code: "conversation.handover", Type: "api", GroupName: "conversation", Method: "ANY", APIPath: "/api/dashboard/conversation/handover_list", SortNo: 480}
|
||||
PermissionConversationRecycle = Permission{Name: "回收会话", Code: "conversation.recycle", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/recycle", SortNo: 490}
|
||||
PermissionConversationLinkCustomer = Permission{Name: "关联会话客户", Code: "conversation.linkCustomer", Type: "api", GroupName: "conversation", Method: "POST", APIPath: "/api/dashboard/conversation/link_customer", SortNo: 495}
|
||||
|
||||
// 工单相关权限
|
||||
PermissionTicketView = Permission{Name: "查看工单", Code: "ticket.view", Type: "api", GroupName: "ticket", Method: "ANY", APIPath: "/api/dashboard/ticket/list", SortNo: 500}
|
||||
PermissionTicketCreate = Permission{Name: "创建工单", Code: "ticket.create", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/create", SortNo: 510}
|
||||
PermissionTicketUpdate = Permission{Name: "更新工单", Code: "ticket.update", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/update", SortNo: 520}
|
||||
PermissionTicketAssign = Permission{Name: "指派工单", Code: "ticket.assign", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/assign", SortNo: 530}
|
||||
PermissionTicketChangeStatus = Permission{Name: "变更工单状态", Code: "ticket.changeStatus", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/change_status", SortNo: 540}
|
||||
PermissionTicketProgress = Permission{Name: "更新工单进展", Code: "ticket.progress", Type: "api", GroupName: "ticket", Method: "POST", APIPath: "/api/dashboard/ticket/progress/create", SortNo: 550}
|
||||
|
||||
// 通知相关权限
|
||||
PermissionNotificationView = Permission{Name: "查看通知", Code: "notification.view", Type: "api", GroupName: "notification", Method: "ANY", APIPath: "/api/dashboard/notification/list", SortNo: 680}
|
||||
PermissionNotificationUpdate = Permission{Name: "更新通知", Code: "notification.update", Type: "api", GroupName: "notification", Method: "POST", APIPath: "/api/dashboard/notification/mark_read", SortNo: 690}
|
||||
|
||||
// 快捷回复相关权限
|
||||
PermissionQuickReplyView = Permission{Name: "查看快捷回复", Code: "quickReply.view", Type: "api", GroupName: "quickReply", Method: "ANY", APIPath: "/api/dashboard/quick-reply/list", SortNo: 610}
|
||||
PermissionQuickReplyCreate = Permission{Name: "创建快捷回复", Code: "quickReply.create", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/create", SortNo: 620}
|
||||
PermissionQuickReplyUpdate = Permission{Name: "更新快捷回复", Code: "quickReply.update", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/update", SortNo: 630}
|
||||
PermissionQuickReplyDelete = Permission{Name: "删除快捷回复", Code: "quickReply.delete", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/delete", SortNo: 640}
|
||||
|
||||
// 标签相关权限
|
||||
PermissionTagView = Permission{Name: "查看标签", Code: "tag.view", Type: "api", GroupName: "tag", Method: "ANY", APIPath: "/api/dashboard/tag/list", SortNo: 550}
|
||||
PermissionTagCreate = Permission{Name: "创建标签", Code: "tag.create", Type: "api", GroupName: "tag", Method: "POST", APIPath: "/api/dashboard/tag/create", SortNo: 560}
|
||||
PermissionTagUpdate = Permission{Name: "更新标签", Code: "tag.update", Type: "api", GroupName: "tag", Method: "POST", APIPath: "/api/dashboard/tag/update", SortNo: 570}
|
||||
PermissionTagDelete = Permission{Name: "删除标签", Code: "tag.delete", Type: "api", GroupName: "tag", Method: "POST", APIPath: "/api/dashboard/tag/delete", SortNo: 580}
|
||||
|
||||
// 公司相关权限
|
||||
PermissionCompanyView = Permission{Name: "查看公司", Code: "company.view", Type: "api", GroupName: "company", Method: "ANY", APIPath: "/api/dashboard/company/list", SortNo: 590}
|
||||
PermissionCompanyCreate = Permission{Name: "创建公司", Code: "company.create", Type: "api", GroupName: "company", Method: "POST", APIPath: "/api/dashboard/company/create", SortNo: 600}
|
||||
PermissionCompanyUpdate = Permission{Name: "更新公司", Code: "company.update", Type: "api", GroupName: "company", Method: "POST", APIPath: "/api/dashboard/company/update", SortNo: 610}
|
||||
PermissionCompanyDelete = Permission{Name: "删除公司", Code: "company.delete", Type: "api", GroupName: "company", Method: "POST", APIPath: "/api/dashboard/company/delete", SortNo: 620}
|
||||
|
||||
// 接入渠道相关权限
|
||||
PermissionChannelView = Permission{Name: "查看接入渠道", Code: "channel.view", Type: "api", GroupName: "channel", Method: "ANY", APIPath: "/api/dashboard/channel/list", SortNo: 625}
|
||||
PermissionChannelCreate = Permission{Name: "创建接入渠道", Code: "channel.create", Type: "api", GroupName: "channel", Method: "POST", APIPath: "/api/dashboard/channel/create", SortNo: 626}
|
||||
PermissionChannelUpdate = Permission{Name: "更新接入渠道", Code: "channel.update", Type: "api", GroupName: "channel", Method: "POST", APIPath: "/api/dashboard/channel/update", SortNo: 627}
|
||||
PermissionChannelDelete = Permission{Name: "删除接入渠道", Code: "channel.delete", Type: "api", GroupName: "channel", Method: "POST", APIPath: "/api/dashboard/channel/delete", SortNo: 628}
|
||||
|
||||
// 企微 Outbox 相关权限
|
||||
|
||||
// 客户相关权限
|
||||
PermissionCustomerView = Permission{Name: "查看客户", Code: "customer.view", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/list", SortNo: 630}
|
||||
PermissionCustomerCreate = Permission{Name: "创建客户", Code: "customer.create", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/create", SortNo: 640}
|
||||
PermissionCustomerUpdate = Permission{Name: "更新客户", Code: "customer.update", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/update", SortNo: 650}
|
||||
PermissionCustomerDelete = Permission{Name: "删除客户", Code: "customer.delete", Type: "api", GroupName: "customer", Method: "POST", APIPath: "/api/dashboard/customer/delete", SortNo: 660}
|
||||
|
||||
// 客服相关权限
|
||||
PermissionAgentView = Permission{Name: "查看客服", Code: "agent.view", Type: "api", GroupName: "agent", Method: "ANY", APIPath: "/api/dashboard/agent/list", SortNo: 610}
|
||||
PermissionAgentCreate = Permission{Name: "创建客服", Code: "agent.create", Type: "api", GroupName: "agent", Method: "POST", APIPath: "/api/dashboard/agent/create", SortNo: 620}
|
||||
PermissionAgentUpdate = Permission{Name: "更新客服", Code: "agent.update", Type: "api", GroupName: "agent", Method: "POST", APIPath: "/api/dashboard/agent/update", SortNo: 630}
|
||||
PermissionAgentDelete = Permission{Name: "删除客服", Code: "agent.delete", Type: "api", GroupName: "agent", Method: "POST", APIPath: "/api/dashboard/agent/delete", SortNo: 640}
|
||||
PermissionAgentUpdateStatus = Permission{Name: "更新客服状态", Code: "agent.updateStatus", Type: "api", GroupName: "agent", Method: "POST", APIPath: "/api/dashboard/agent/update_status", SortNo: 650}
|
||||
PermissionAgentConfig = Permission{Name: "配置客服服务规则", Code: "agent.config", Type: "api", GroupName: "agent", Method: "POST", APIPath: "/api/dashboard/agent/update_service_config", SortNo: 660}
|
||||
|
||||
// 客服组相关权限
|
||||
PermissionAgentTeamView = Permission{Name: "查看客服组", Code: "agentTeam.view", Type: "api", GroupName: "agentTeam", Method: "ANY", APIPath: "/api/dashboard/agent-team/list", SortNo: 710}
|
||||
PermissionAgentTeamCreate = Permission{Name: "创建客服组", Code: "agentTeam.create", Type: "api", GroupName: "agentTeam", Method: "POST", APIPath: "/api/dashboard/agent-team/create", SortNo: 720}
|
||||
PermissionAgentTeamUpdate = Permission{Name: "更新客服组", Code: "agentTeam.update", Type: "api", GroupName: "agentTeam", Method: "POST", APIPath: "/api/dashboard/agent-team/update", SortNo: 730}
|
||||
PermissionAgentTeamDelete = Permission{Name: "删除客服组", Code: "agentTeam.delete", Type: "api", GroupName: "agentTeam", Method: "POST", APIPath: "/api/dashboard/agent-team/delete", SortNo: 740}
|
||||
|
||||
// 客服组排班相关权限
|
||||
PermissionAgentTeamScheduleView = Permission{Name: "查看客服组排班", Code: "agentTeamSchedule.view", Type: "api", GroupName: "agentTeamSchedule", Method: "ANY", APIPath: "/api/dashboard/agent-team-schedule/list", SortNo: 810}
|
||||
PermissionAgentTeamScheduleCreate = Permission{Name: "创建客服组排班", Code: "agentTeamSchedule.create", Type: "api", GroupName: "agentTeamSchedule", Method: "POST", APIPath: "/api/dashboard/agent-team-schedule/create", SortNo: 820}
|
||||
PermissionAgentTeamScheduleUpdate = Permission{Name: "更新客服组排班", Code: "agentTeamSchedule.update", Type: "api", GroupName: "agentTeamSchedule", Method: "POST", APIPath: "/api/dashboard/agent-team-schedule/update", SortNo: 830}
|
||||
PermissionAgentTeamScheduleDelete = Permission{Name: "删除客服组排班", Code: "agentTeamSchedule.delete", Type: "api", GroupName: "agentTeamSchedule", Method: "POST", APIPath: "/api/dashboard/agent-team-schedule/delete", SortNo: 840}
|
||||
PermissionAgentTeamScheduleBatchGenerate = Permission{Name: "批量生成客服组排班", Code: "agentTeamSchedule.batchGenerate", Type: "api", GroupName: "agentTeamSchedule", Method: "POST", APIPath: "/api/dashboard/agent-team-schedule/batch_generate", SortNo: 850}
|
||||
|
||||
// 文件资源相关权限
|
||||
PermissionAssetView = Permission{Name: "查看文件资源", Code: "asset.view", Type: "api", GroupName: "asset", Method: "ANY", APIPath: "/api/dashboard/asset/list", SortNo: 1210}
|
||||
PermissionAssetCreate = Permission{Name: "上传文件资源", Code: "asset.create", Type: "api", GroupName: "asset", Method: "POST", APIPath: "/api/dashboard/asset/create", SortNo: 1220}
|
||||
PermissionAssetDelete = Permission{Name: "删除文件资源", Code: "asset.delete", Type: "api", GroupName: "asset", Method: "POST", APIPath: "/api/dashboard/asset/delete", SortNo: 1230}
|
||||
|
||||
// AI Agent 相关权限
|
||||
PermissionAIAgentView = Permission{Name: "查看 AI Agent", Code: "aiAgent.view", Type: "api", GroupName: "aiAgent", Method: "ANY", APIPath: "/api/dashboard/ai-agent/list", SortNo: 1310}
|
||||
PermissionAIAgentCreate = Permission{Name: "创建 AI Agent", Code: "aiAgent.create", Type: "api", GroupName: "aiAgent", Method: "POST", APIPath: "/api/dashboard/ai-agent/create", SortNo: 1320}
|
||||
PermissionAIAgentUpdate = Permission{Name: "更新 AI Agent", Code: "aiAgent.update", Type: "api", GroupName: "aiAgent", Method: "POST", APIPath: "/api/dashboard/ai-agent/update", SortNo: 1330}
|
||||
PermissionAIAgentDelete = Permission{Name: "删除 AI Agent", Code: "aiAgent.delete", Type: "api", GroupName: "aiAgent", Method: "POST", APIPath: "/api/dashboard/ai-agent/delete", SortNo: 1340}
|
||||
|
||||
// AI 配置相关权限
|
||||
PermissionAIConfigView = Permission{Name: "查看 AI 配置", Code: "aiConfig.view", Type: "api", GroupName: "aiConfig", Method: "ANY", APIPath: "/api/dashboard/ai-config/list", SortNo: 1390}
|
||||
PermissionAIConfigCreate = Permission{Name: "创建 AI 配置", Code: "aiConfig.create", Type: "api", GroupName: "aiConfig", Method: "POST", APIPath: "/api/dashboard/ai-config/create", SortNo: 1400}
|
||||
PermissionAIConfigUpdate = Permission{Name: "更新 AI 配置", Code: "aiConfig.update", Type: "api", GroupName: "aiConfig", Method: "POST", APIPath: "/api/dashboard/ai-config/update", SortNo: 1410}
|
||||
PermissionAIConfigDelete = Permission{Name: "删除 AI 配置", Code: "aiConfig.delete", Type: "api", GroupName: "aiConfig", Method: "POST", APIPath: "/api/dashboard/ai-config/delete", SortNo: 1420}
|
||||
|
||||
// 知识库相关权限
|
||||
PermissionKnowledgeBaseView = Permission{Name: "查看知识库", Code: "knowledgeBase.view", Type: "api", GroupName: "knowledgeBase", Method: "ANY", APIPath: "/api/dashboard/knowledge-base/list", SortNo: 1410}
|
||||
PermissionKnowledgeBaseCreate = Permission{Name: "创建知识库", Code: "knowledgeBase.create", Type: "api", GroupName: "knowledgeBase", Method: "POST", APIPath: "/api/dashboard/knowledge-base/create", SortNo: 1420}
|
||||
PermissionKnowledgeBaseUpdate = Permission{Name: "更新知识库", Code: "knowledgeBase.update", Type: "api", GroupName: "knowledgeBase", Method: "POST", APIPath: "/api/dashboard/knowledge-base/update", SortNo: 1430}
|
||||
PermissionKnowledgeBaseDelete = Permission{Name: "删除知识库", Code: "knowledgeBase.delete", Type: "api", GroupName: "knowledgeBase", Method: "POST", APIPath: "/api/dashboard/knowledge-base/delete", SortNo: 1440}
|
||||
|
||||
// 知识文档相关权限
|
||||
PermissionKnowledgeDocumentView = Permission{Name: "查看知识文档", Code: "knowledgeDocument.view", Type: "api", GroupName: "knowledgeDocument", Method: "ANY", APIPath: "/api/dashboard/knowledge-document/list", SortNo: 1510}
|
||||
PermissionKnowledgeDocumentCreate = Permission{Name: "创建知识文档", Code: "knowledgeDocument.create", Type: "api", GroupName: "knowledgeDocument", Method: "POST", APIPath: "/api/dashboard/knowledge-document/create", SortNo: 1520}
|
||||
PermissionKnowledgeDocumentUpdate = Permission{Name: "更新知识文档", Code: "knowledgeDocument.update", Type: "api", GroupName: "knowledgeDocument", Method: "POST", APIPath: "/api/dashboard/knowledge-document/update", SortNo: 1530}
|
||||
PermissionKnowledgeDocumentDelete = Permission{Name: "删除知识文档", Code: "knowledgeDocument.delete", Type: "api", GroupName: "knowledgeDocument", Method: "POST", APIPath: "/api/dashboard/knowledge-document/delete", SortNo: 1540}
|
||||
PermissionKnowledgeFAQView = Permission{Name: "查看知识FAQ", Code: "knowledgeFAQ.view", Type: "api", GroupName: "knowledgeFAQ", Method: "ANY", APIPath: "/api/dashboard/knowledge-faq/list", SortNo: 1550}
|
||||
PermissionKnowledgeFAQCreate = Permission{Name: "创建知识FAQ", Code: "knowledgeFAQ.create", Type: "api", GroupName: "knowledgeFAQ", Method: "POST", APIPath: "/api/dashboard/knowledge-faq/create", SortNo: 1560}
|
||||
PermissionKnowledgeFAQUpdate = Permission{Name: "更新知识FAQ", Code: "knowledgeFAQ.update", Type: "api", GroupName: "knowledgeFAQ", Method: "POST", APIPath: "/api/dashboard/knowledge-faq/update", SortNo: 1570}
|
||||
PermissionKnowledgeFAQDelete = Permission{Name: "删除知识FAQ", Code: "knowledgeFAQ.delete", Type: "api", GroupName: "knowledgeFAQ", Method: "POST", APIPath: "/api/dashboard/knowledge-faq/delete", SortNo: 1580}
|
||||
|
||||
// 支持中心相关权限
|
||||
PermissionDocPageView = Permission{Name: "查看文档中心页面", Code: "docPage.view", Type: "api", GroupName: "docPage", Method: "ANY", APIPath: "/api/dashboard/doc-page/list", SortNo: 1590}
|
||||
PermissionDocPageCreate = Permission{Name: "创建文档中心页面", Code: "docPage.create", Type: "api", GroupName: "docPage", Method: "POST", APIPath: "/api/dashboard/doc-page/create", SortNo: 1591}
|
||||
PermissionDocPageUpdate = Permission{Name: "更新文档中心页面", Code: "docPage.update", Type: "api", GroupName: "docPage", Method: "POST", APIPath: "/api/dashboard/doc-page/update", SortNo: 1592}
|
||||
PermissionDocPageDelete = Permission{Name: "删除文档中心页面", Code: "docPage.delete", Type: "api", GroupName: "docPage", Method: "POST", APIPath: "/api/dashboard/doc-page/delete", SortNo: 1593}
|
||||
PermissionCommunityView = Permission{Name: "查看支持中心社区", Code: "supportCommunity.view", Type: "api", GroupName: "supportCommunity", Method: "ANY", APIPath: "/api/dashboard/support-community/posts/list", SortNo: 1594}
|
||||
PermissionCommunityUpdate = Permission{Name: "管理支持中心社区", Code: "supportCommunity.update", Type: "api", GroupName: "supportCommunity", Method: "POST", APIPath: "/api/dashboard/support-community/posts/moderate", SortNo: 1595}
|
||||
PermissionSupportConfigView = Permission{Name: "查看支持中心配置", Code: "supportConfig.view", Type: "api", GroupName: "supportConfig", Method: "GET", APIPath: "/api/dashboard/support/config", SortNo: 1596}
|
||||
PermissionSupportConfigUpdate = Permission{Name: "更新支持中心配置", Code: "supportConfig.update", Type: "api", GroupName: "supportConfig", Method: "POST", APIPath: "/api/dashboard/support/config/save", SortNo: 1597}
|
||||
PermissionBrandingView = Permission{Name: "查看平台品牌配置", Code: "branding.view", Type: "api", GroupName: "branding", Method: "GET", APIPath: "/api/dashboard/branding", SortNo: 1598}
|
||||
PermissionBrandingUpdate = Permission{Name: "更新平台品牌配置", Code: "branding.update", Type: "api", GroupName: "branding", Method: "POST", APIPath: "/api/dashboard/branding/save", SortNo: 1599}
|
||||
|
||||
// Skill 定义相关权限
|
||||
PermissionSkillDefinitionView = Permission{Name: "查看技能定义", Code: "skillDefinition.view", Type: "api", GroupName: "skillDefinition", Method: "ANY", APIPath: "/api/dashboard/skill-definition/list", SortNo: 1610}
|
||||
PermissionSkillDefinitionCreate = Permission{Name: "创建技能定义", Code: "skillDefinition.create", Type: "api", GroupName: "skillDefinition", Method: "POST", APIPath: "/api/dashboard/skill-definition/create", SortNo: 1620}
|
||||
PermissionSkillDefinitionUpdate = Permission{Name: "更新技能定义", Code: "skillDefinition.update", Type: "api", GroupName: "skillDefinition", Method: "POST", APIPath: "/api/dashboard/skill-definition/update", SortNo: 1630}
|
||||
PermissionSkillDefinitionDelete = Permission{Name: "删除技能定义", Code: "skillDefinition.delete", Type: "api", GroupName: "skillDefinition", Method: "POST", APIPath: "/api/dashboard/skill-definition/delete", SortNo: 1640}
|
||||
|
||||
// MCP 调试相关权限
|
||||
PermissionMCPView = Permission{Name: "查看MCP调试信息", Code: "mcp.view", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/list_tools", SortNo: 1710}
|
||||
PermissionMCPCall = Permission{Name: "调用MCP工具", Code: "mcp.call", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/call_tool", SortNo: 1720}
|
||||
)
|
||||
|
||||
// Permissions 内置权限列表
|
||||
var Permissions = []Permission{
|
||||
PermissionUserView,
|
||||
PermissionUserCreate,
|
||||
PermissionUserUpdate,
|
||||
PermissionUserDelete,
|
||||
PermissionUserAssignRole,
|
||||
PermissionRoleView,
|
||||
PermissionRoleCreate,
|
||||
PermissionRoleUpdate,
|
||||
PermissionRoleDelete,
|
||||
PermissionRoleAssignPermission,
|
||||
PermissionPermissionView,
|
||||
PermissionPermissionSync,
|
||||
PermissionSessionView,
|
||||
PermissionSessionRevoke,
|
||||
PermissionConversationView,
|
||||
PermissionConversationAssign,
|
||||
PermissionConversationTransfer,
|
||||
PermissionConversationClose,
|
||||
PermissionConversationSend,
|
||||
PermissionConversationTag,
|
||||
PermissionConversationHandover,
|
||||
PermissionConversationRecycle,
|
||||
PermissionConversationLinkCustomer,
|
||||
PermissionTicketView,
|
||||
PermissionTicketCreate,
|
||||
PermissionTicketUpdate,
|
||||
PermissionTicketAssign,
|
||||
PermissionTicketChangeStatus,
|
||||
PermissionTicketProgress,
|
||||
PermissionNotificationView,
|
||||
PermissionNotificationUpdate,
|
||||
PermissionQuickReplyView,
|
||||
PermissionQuickReplyCreate,
|
||||
PermissionQuickReplyUpdate,
|
||||
PermissionQuickReplyDelete,
|
||||
PermissionTagView,
|
||||
PermissionTagCreate,
|
||||
PermissionTagUpdate,
|
||||
PermissionTagDelete,
|
||||
PermissionCompanyView,
|
||||
PermissionCompanyCreate,
|
||||
PermissionCompanyUpdate,
|
||||
PermissionCompanyDelete,
|
||||
PermissionChannelView,
|
||||
PermissionChannelCreate,
|
||||
PermissionChannelUpdate,
|
||||
PermissionChannelDelete,
|
||||
PermissionCustomerView,
|
||||
PermissionCustomerCreate,
|
||||
PermissionCustomerUpdate,
|
||||
PermissionCustomerDelete,
|
||||
PermissionAgentView,
|
||||
PermissionAgentCreate,
|
||||
PermissionAgentUpdate,
|
||||
PermissionAgentDelete,
|
||||
PermissionAgentUpdateStatus,
|
||||
PermissionAgentConfig,
|
||||
PermissionAgentTeamView,
|
||||
PermissionAgentTeamCreate,
|
||||
PermissionAgentTeamUpdate,
|
||||
PermissionAgentTeamDelete,
|
||||
PermissionAgentTeamScheduleView,
|
||||
PermissionAgentTeamScheduleCreate,
|
||||
PermissionAgentTeamScheduleUpdate,
|
||||
PermissionAgentTeamScheduleDelete,
|
||||
PermissionAgentTeamScheduleBatchGenerate,
|
||||
PermissionAssetView,
|
||||
PermissionAssetCreate,
|
||||
PermissionAssetDelete,
|
||||
PermissionAIAgentView,
|
||||
PermissionAIAgentCreate,
|
||||
PermissionAIAgentUpdate,
|
||||
PermissionAIAgentDelete,
|
||||
PermissionAIConfigView,
|
||||
PermissionAIConfigCreate,
|
||||
PermissionAIConfigUpdate,
|
||||
PermissionAIConfigDelete,
|
||||
PermissionKnowledgeBaseView,
|
||||
PermissionKnowledgeBaseCreate,
|
||||
PermissionKnowledgeBaseUpdate,
|
||||
PermissionKnowledgeBaseDelete,
|
||||
PermissionKnowledgeDocumentView,
|
||||
PermissionKnowledgeDocumentCreate,
|
||||
PermissionKnowledgeDocumentUpdate,
|
||||
PermissionKnowledgeDocumentDelete,
|
||||
PermissionKnowledgeFAQView,
|
||||
PermissionKnowledgeFAQCreate,
|
||||
PermissionKnowledgeFAQUpdate,
|
||||
PermissionKnowledgeFAQDelete,
|
||||
PermissionDocPageView,
|
||||
PermissionDocPageCreate,
|
||||
PermissionDocPageUpdate,
|
||||
PermissionDocPageDelete,
|
||||
PermissionCommunityView,
|
||||
PermissionCommunityUpdate,
|
||||
PermissionSupportConfigView,
|
||||
PermissionSupportConfigUpdate,
|
||||
PermissionBrandingView,
|
||||
PermissionBrandingUpdate,
|
||||
PermissionSkillDefinitionView,
|
||||
PermissionSkillDefinitionCreate,
|
||||
PermissionSkillDefinitionUpdate,
|
||||
PermissionSkillDefinitionDelete,
|
||||
PermissionMCPView,
|
||||
PermissionMCPCall,
|
||||
}
|
||||
|
||||
// PermissionMap 权限映射,用于通过 Code 查找 Permission
|
||||
var PermissionMap = make(map[string]Permission)
|
||||
|
||||
// init 初始化 PermissionMap
|
||||
func init() {
|
||||
normalizeBuiltinPermissionNames()
|
||||
for _, permission := range Permissions {
|
||||
PermissionMap[permission.Code] = permission
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBuiltinPermissionNames() {
|
||||
for i := range Permissions {
|
||||
Permissions[i].Name = builtinPermissionName(Permissions[i].Code, Permissions[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func builtinPermissionName(code string, fallback string) string {
|
||||
if name, ok := builtinPermissionNameOverrides[code]; ok {
|
||||
return name
|
||||
}
|
||||
resourceKey, actionKey, ok := splitPermissionCode(code)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
action, ok := builtinPermissionActionLabels[actionKey]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
resource, ok := builtinPermissionResourceLabels[resourceKey]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return action + " " + resource
|
||||
}
|
||||
|
||||
func splitPermissionCode(code string) (string, string, bool) {
|
||||
for i := 0; i < len(code); i++ {
|
||||
if code[i] == '.' {
|
||||
return code[:i], code[i+1:], i > 0 && i < len(code)-1
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var builtinPermissionActionLabels = map[string]string{
|
||||
"view": "View",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"assignRole": "Assign roles to",
|
||||
"assignPermission": "Assign permissions to",
|
||||
"sync": "Sync",
|
||||
"revoke": "Revoke",
|
||||
"assign": "Assign",
|
||||
"transfer": "Transfer",
|
||||
"close": "Close",
|
||||
"send": "Send",
|
||||
"tag": "Manage tags for",
|
||||
"handover": "Handle handoffs for",
|
||||
"recycle": "Recycle",
|
||||
"linkCustomer": "Link customers to",
|
||||
"changeStatus": "Change status for",
|
||||
"progress": "Update progress for",
|
||||
"updateStatus": "Update status for",
|
||||
"config": "Configure service rules for",
|
||||
"batchGenerate": "Batch generate",
|
||||
"call": "Call",
|
||||
}
|
||||
|
||||
var builtinPermissionResourceLabels = map[string]string{
|
||||
"user": "users",
|
||||
"role": "roles",
|
||||
"permission": "permissions",
|
||||
"session": "sessions",
|
||||
"conversation": "conversations",
|
||||
"ticket": "tickets",
|
||||
"notification": "notifications",
|
||||
"quickReply": "quick replies",
|
||||
"tag": "tags",
|
||||
"company": "companies",
|
||||
"channel": "channels",
|
||||
"customer": "customers",
|
||||
"agent": "agents",
|
||||
"agentTeam": "agent teams",
|
||||
"agentTeamSchedule": "agent team schedules",
|
||||
"asset": "file assets",
|
||||
"aiAgent": "AI Agents",
|
||||
"aiConfig": "AI configurations",
|
||||
"knowledgeBase": "knowledge bases",
|
||||
"knowledgeDocument": "knowledge documents",
|
||||
"knowledgeFAQ": "knowledge FAQs",
|
||||
"skillDefinition": "Skill definitions",
|
||||
"mcp": "MCP tools",
|
||||
"docPage": "support docs",
|
||||
"supportCommunity": "support community",
|
||||
"supportConfig": "support center configuration",
|
||||
"branding": "branding settings",
|
||||
}
|
||||
|
||||
var builtinPermissionNameOverrides = map[string]string{
|
||||
"user.assignRole": "Assign user roles",
|
||||
"role.assignPermission": "Assign role permissions",
|
||||
"session.revoke": "Revoke sessions",
|
||||
"conversation.send": "Send conversation messages",
|
||||
"conversation.linkCustomer": "Link conversation customer",
|
||||
"ticket.changeStatus": "Change ticket status",
|
||||
"ticket.progress": "Update ticket progress",
|
||||
"agent.config": "Configure agent service rules",
|
||||
"agentTeamSchedule.batchGenerate": "Batch generate agent team schedules",
|
||||
"mcp.view": "View MCP debug information",
|
||||
"mcp.call": "Call MCP tools",
|
||||
"docPage.view": "View support docs",
|
||||
"docPage.create": "Create support docs",
|
||||
"docPage.update": "Update support docs",
|
||||
"docPage.delete": "Delete support docs",
|
||||
"supportCommunity.view": "View support community",
|
||||
"supportCommunity.update": "Moderate support community",
|
||||
"supportConfig.view": "View support center configuration",
|
||||
"supportConfig.update": "Update support center configuration",
|
||||
"branding.view": "View branding settings",
|
||||
"branding.update": "Update branding settings",
|
||||
}
|
||||
|
||||
type RoleSpec struct {
|
||||
Name string
|
||||
Code string
|
||||
SortNo int
|
||||
}
|
||||
|
||||
var Roles = []RoleSpec{
|
||||
{Name: "Super Admin", Code: RoleCodeSuperAdmin, SortNo: 1},
|
||||
{Name: "Admin", Code: RoleCodeAdmin, SortNo: 2},
|
||||
{Name: "Support Team Lead", Code: RoleCodeCsTeamLeader, SortNo: 3},
|
||||
{Name: "Support Agent", Code: RoleCodeCsUser, SortNo: 4},
|
||||
}
|
||||
|
||||
var RolePermissions = map[string][]Permission{
|
||||
RoleCodeSuperAdmin: Permissions,
|
||||
RoleCodeAdmin: {
|
||||
PermissionUserView, PermissionUserCreate, PermissionUserUpdate, PermissionUserAssignRole,
|
||||
PermissionRoleView, PermissionRoleCreate, PermissionRoleUpdate, PermissionRoleAssignPermission,
|
||||
PermissionPermissionView, PermissionPermissionSync,
|
||||
PermissionSessionView, PermissionSessionRevoke,
|
||||
PermissionConversationView, PermissionConversationAssign, PermissionConversationTransfer, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||
PermissionNotificationView, PermissionNotificationUpdate,
|
||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||
PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete,
|
||||
PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate, PermissionChannelDelete,
|
||||
PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate, PermissionCustomerDelete,
|
||||
PermissionAgentView, PermissionAgentCreate, PermissionAgentUpdate, PermissionAgentDelete, PermissionAgentUpdateStatus, PermissionAgentConfig,
|
||||
PermissionAgentTeamView, PermissionAgentTeamCreate, PermissionAgentTeamUpdate, PermissionAgentTeamDelete,
|
||||
PermissionAgentTeamScheduleView, PermissionAgentTeamScheduleCreate, PermissionAgentTeamScheduleUpdate, PermissionAgentTeamScheduleDelete, PermissionAgentTeamScheduleBatchGenerate,
|
||||
PermissionAssetView, PermissionAssetCreate, PermissionAssetDelete,
|
||||
PermissionAIAgentView, PermissionAIAgentCreate, PermissionAIAgentUpdate, PermissionAIAgentDelete,
|
||||
PermissionAIConfigView, PermissionAIConfigCreate, PermissionAIConfigUpdate, PermissionAIConfigDelete,
|
||||
PermissionSkillDefinitionView, PermissionSkillDefinitionCreate, PermissionSkillDefinitionUpdate, PermissionSkillDefinitionDelete,
|
||||
},
|
||||
RoleCodeCsTeamLeader: {
|
||||
PermissionUserView,
|
||||
PermissionRoleView,
|
||||
PermissionPermissionView,
|
||||
PermissionSessionView,
|
||||
PermissionConversationView, PermissionConversationClose, PermissionConversationSend, PermissionConversationTag, PermissionConversationHandover, PermissionConversationRecycle, PermissionConversationLinkCustomer,
|
||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||
PermissionNotificationView, PermissionNotificationUpdate,
|
||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||
PermissionCompanyView,
|
||||
PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate,
|
||||
PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate,
|
||||
PermissionAgentView, PermissionAgentUpdate,
|
||||
PermissionAgentTeamView,
|
||||
PermissionAgentTeamScheduleView, PermissionAgentTeamScheduleCreate, PermissionAgentTeamScheduleUpdate, PermissionAgentTeamScheduleDelete, PermissionAgentTeamScheduleBatchGenerate,
|
||||
PermissionAssetView, PermissionAssetCreate, PermissionAssetDelete,
|
||||
PermissionAIAgentView, PermissionAIAgentCreate, PermissionAIAgentUpdate,
|
||||
PermissionAIConfigView,
|
||||
PermissionSkillDefinitionView, PermissionSkillDefinitionCreate, PermissionSkillDefinitionUpdate,
|
||||
},
|
||||
RoleCodeCsUser: {
|
||||
PermissionUserView,
|
||||
PermissionRoleView,
|
||||
PermissionPermissionView,
|
||||
PermissionConversationView,
|
||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketProgress,
|
||||
PermissionNotificationView, PermissionNotificationUpdate,
|
||||
PermissionQuickReplyView,
|
||||
PermissionTagView,
|
||||
PermissionCompanyView,
|
||||
PermissionChannelView,
|
||||
PermissionCustomerView,
|
||||
PermissionAssetView,
|
||||
PermissionAgentView,
|
||||
PermissionAgentTeamView,
|
||||
PermissionAgentTeamScheduleView,
|
||||
PermissionAIAgentView,
|
||||
PermissionAIConfigView,
|
||||
PermissionSkillDefinitionView,
|
||||
},
|
||||
}
|
||||
|
||||
func PermissionCodes() []string {
|
||||
ret := make([]string, 0, len(Permissions))
|
||||
for _, permission := range Permissions {
|
||||
ret = append(ret, permission.Code)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
46
internal/pkg/constants/auth_test.go
Normal file
46
internal/pkg/constants/auth_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package constants
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuiltinAuthSeedNamesDefaultToEnglish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if BootstrapAdminNickname != "Super Admin" {
|
||||
t.Fatalf("BootstrapAdminNickname = %q, want %q", BootstrapAdminNickname, "Super Admin")
|
||||
}
|
||||
|
||||
roles := map[string]string{}
|
||||
for _, role := range Roles {
|
||||
roles[role.Code] = role.Name
|
||||
}
|
||||
|
||||
tests := map[string]string{
|
||||
RoleCodeSuperAdmin: "Super Admin",
|
||||
RoleCodeAdmin: "Admin",
|
||||
RoleCodeCsTeamLeader: "Support Team Lead",
|
||||
RoleCodeCsUser: "Support Agent",
|
||||
}
|
||||
for code, want := range tests {
|
||||
if got := roles[code]; got != want {
|
||||
t.Fatalf("role %s name = %q, want %q", code, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
permissions := map[string]string{}
|
||||
for _, permission := range Permissions {
|
||||
permissions[permission.Code] = permission.Name
|
||||
}
|
||||
|
||||
permissionTests := map[string]string{
|
||||
"user.view": "View users",
|
||||
"ticket.create": "Create tickets",
|
||||
"conversation.send": "Send conversation messages",
|
||||
"channel.view": "View channels",
|
||||
"agent.view": "View agents",
|
||||
}
|
||||
for code, want := range permissionTests {
|
||||
if got := permissions[code]; got != want {
|
||||
t.Fatalf("permission %s name = %q, want %q", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
6
internal/pkg/constants/constants.go
Normal file
6
internal/pkg/constants/constants.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
SystemAuditUserID int64 = 0
|
||||
SystemAuditUserName string = "system"
|
||||
)
|
||||
83
internal/pkg/dto/dto.go
Normal file
83
internal/pkg/dto/dto.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package dto
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AuthPrincipal struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
UserType enums.UserType
|
||||
Status enums.Status
|
||||
Roles []string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type TelegramChannelConfig struct {
|
||||
BotToken string `json:"botToken"`
|
||||
BotUsername string `json:"botUsername,omitempty"`
|
||||
WebhookSecret string `json:"webhookSecret,omitempty"`
|
||||
WelcomeMessage string `json:"welcomeMessage,omitempty"`
|
||||
}
|
||||
|
||||
type ZaloOAChannelConfig struct {
|
||||
AppID string `json:"appId,omitempty"`
|
||||
OAID string `json:"oaId,omitempty"`
|
||||
SecretKey string `json:"secretKey,omitempty"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
WebhookSecret string `json:"webhookSecret,omitempty"`
|
||||
WelcomeMessage string `json:"welcomeMessage,omitempty"`
|
||||
}
|
||||
|
||||
type SlackChannelConfig struct {
|
||||
BotToken string `json:"botToken,omitempty"` // xoxb-... Bot Token
|
||||
SigningSecret string `json:"signingSecret,omitempty"` // Slack Signing Secret
|
||||
AppID string `json:"appId,omitempty"` // Slack App ID
|
||||
TeamID string `json:"teamId,omitempty"` // Slack Workspace Team ID
|
||||
TeamName string `json:"teamName,omitempty"` // Slack Workspace Team Name
|
||||
DefaultChannel string `json:"defaultChannel,omitempty"` // Default channel to post
|
||||
}
|
||||
|
||||
type EmailChannelConfig struct {
|
||||
EmailAddress string `json:"emailAddress,omitempty"`
|
||||
ForwardingAddress string `json:"forwardingAddress,omitempty"`
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
SMTPHost string `json:"smtpHost,omitempty"`
|
||||
SMTPPort int `json:"smtpPort,omitempty"`
|
||||
SMTPUser string `json:"smtpUser,omitempty"`
|
||||
SMTPPassword string `json:"smtpPassword,omitempty"`
|
||||
// SMTPUseTLS is a pointer so "not set" stays distinguishable from an explicit
|
||||
// false. A plain bool cannot say that, and inheriting the system value is the
|
||||
// wrong answer for a channel deliberately bound to a plaintext test server.
|
||||
SMTPUseTLS *bool `json:"smtpUseTls,omitempty"`
|
||||
// SMTPAllowInsecure accepts a self-signed certificate and skips opportunistic
|
||||
// STARTTLS, so a channel can be bound to a local test SMTP server. Never
|
||||
// enable it for a real mailbox.
|
||||
SMTPAllowInsecure bool `json:"smtpAllowInsecure,omitempty"`
|
||||
WebhookSecret string `json:"webhookSecret,omitempty"`
|
||||
WelcomeMessage string `json:"welcomeMessage,omitempty"`
|
||||
}
|
||||
|
||||
type DiscordChannelConfig struct {
|
||||
GuildID string `json:"guildId,omitempty"`
|
||||
GuildName string `json:"guildName,omitempty"`
|
||||
ChannelScope string `json:"channelScope,omitempty"` // all | dm_only
|
||||
BotToken string `json:"botToken,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
WebhookSecret string `json:"webhookSecret,omitempty"`
|
||||
WelcomeMessage string `json:"welcomeMessage,omitempty"`
|
||||
}
|
||||
96
internal/pkg/dto/request/admin_request.go
Normal file
96
internal/pkg/dto/request/admin_request.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type RevokeSessionRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type RevokeUserSessionsRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Mobile *string `json:"mobile"`
|
||||
Email *string `json:"email"`
|
||||
Remark string `json:"remark"`
|
||||
RoleIDs []int64 `json:"roleIds"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Mobile *string `json:"mobile"`
|
||||
Email *string `json:"email"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type DeleteUserRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateUserStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type AssignRoleRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
RoleIDs []int64 `json:"roleIds"`
|
||||
}
|
||||
|
||||
type CreateRoleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateRoleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type DeleteRoleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateRoleStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status enums.Status `json:"status"`
|
||||
}
|
||||
|
||||
type AssignPermissionRequest struct {
|
||||
RoleID int64 `json:"roleId"`
|
||||
PermissionIDs []int64 `json:"permissionIds"`
|
||||
}
|
||||
|
||||
type CreateConversationTagRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Status int `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateConversationTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Status int `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type DeleteConversationTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
14
internal/pkg/dto/request/agent_evaluation_request.go
Normal file
14
internal/pkg/dto/request/agent_evaluation_request.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package request
|
||||
|
||||
type RunAgentEvaluationRequest struct {
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
Cases []AgentEvaluationCase `json:"cases"`
|
||||
}
|
||||
|
||||
type AgentEvaluationCase struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Expect map[string]any `json:"expect,omitempty"`
|
||||
}
|
||||
80
internal/pkg/dto/request/agent_request.go
Normal file
80
internal/pkg/dto/request/agent_request.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type CreateAgentProfileRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AgentCode string `json:"agentCode"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Avatar string `json:"avatar"`
|
||||
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
|
||||
MaxConcurrentCount int `json:"maxConcurrentCount"`
|
||||
PriorityLevel int `json:"priorityLevel"`
|
||||
AutoAssignEnabled bool `json:"autoAssignEnabled"`
|
||||
ReceiveOfflineMessage bool `json:"receiveOfflineMessage"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateAgentProfileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAgentProfileRequest
|
||||
}
|
||||
|
||||
type DeleteAgentProfileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type CreateAgentTeamRequest struct {
|
||||
Name string `json:"name"`
|
||||
LeaderUserID int64 `json:"leaderUserId"`
|
||||
Status int `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateAgentTeamRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LeaderUserID int64 `json:"leaderUserId"`
|
||||
Status int `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type DeleteAgentTeamRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type CreateAgentTeamScheduleRequest struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
StartAt string `json:"startAt"`
|
||||
EndAt string `json:"endAt"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateAgentTeamScheduleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAgentTeamScheduleRequest
|
||||
}
|
||||
|
||||
type DeleteAgentTeamScheduleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleCalendarRequest struct {
|
||||
StartAt string `json:"startAt"`
|
||||
EndAt string `json:"endAt"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchRequest struct {
|
||||
TeamIDs []int64 `json:"teamIds"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Weekdays []int `json:"weekdays"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Remark string `json:"remark"`
|
||||
Locale string `json:"-"`
|
||||
}
|
||||
10
internal/pkg/dto/request/agent_run_request.go
Normal file
10
internal/pkg/dto/request/agent_run_request.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type SaveAgentRunQualityFeedbackRequest struct {
|
||||
AgentRunID int64 `json:"agentRunId"`
|
||||
ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"`
|
||||
EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
9
internal/pkg/dto/request/ai_copilot_request.go
Normal file
9
internal/pkg/dto/request/ai_copilot_request.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package request
|
||||
|
||||
type SuggestReplyRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
}
|
||||
|
||||
type SummarizeConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
}
|
||||
106
internal/pkg/dto/request/ai_request.go
Normal file
106
internal/pkg/dto/request/ai_request.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AIAgentMCPToolRequest struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RequireConfirmation bool `json:"requireConfirmation"`
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
|
||||
type AIAgentWorkflowBindingRequest struct {
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type CreateAIConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider enums.AIProvider `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
ModelType enums.AIModelType `json:"modelType"`
|
||||
ModelName string `json:"modelName"`
|
||||
Dimension int `json:"dimension"`
|
||||
MaxContextTokens int `json:"maxContextTokens"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
MaxRetryCount int `json:"maxRetryCount"`
|
||||
RPMLimit int `json:"rpmLimit"`
|
||||
TPMLimit int `json:"tpmLimit"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateAIConfigRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAIConfigRequest
|
||||
}
|
||||
|
||||
type DeleteAIConfigRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateAIConfigStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status enums.Status `json:"status"`
|
||||
}
|
||||
|
||||
type CreateAIAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
TeamIDs []int64 `json:"teamIds"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
MCPTools []AIAgentMCPToolRequest `json:"mcpTools"`
|
||||
WorkflowBindings []AIAgentWorkflowBindingRequest `json:"workflowBindings"`
|
||||
AutoHandoffOnFailure *bool `json:"autoHandoffOnFailure"`
|
||||
HandoffMessage string `json:"handoffMessage"`
|
||||
OffHoursMessage string `json:"offHoursMessage"`
|
||||
}
|
||||
|
||||
type UpdateAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAIAgentRequest
|
||||
}
|
||||
|
||||
type DeleteAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type PublishAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type RollbackAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
|
||||
type RollbackAIAgentRolloutRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateAIAgentStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
36
internal/pkg/dto/request/ai_workflow_request.go
Normal file
36
internal/pkg/dto/request/ai_workflow_request.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/ai/workflow/dsl"
|
||||
|
||||
type CreateAIWorkflowRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
}
|
||||
|
||||
type UpdateAIWorkflowRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateAIWorkflowRequest
|
||||
}
|
||||
|
||||
type DeleteAIWorkflowRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type ValidateAIWorkflowRequest struct {
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
}
|
||||
|
||||
type PublishAIWorkflowRequest struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
}
|
||||
|
||||
type AIWorkflowVersionListRequest struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
}
|
||||
|
||||
type RestoreAIWorkflowVersionRequest struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
}
|
||||
9
internal/pkg/dto/request/asset_request.go
Normal file
9
internal/pkg/dto/request/asset_request.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package request
|
||||
|
||||
type CreateAssetRequest struct {
|
||||
Prefix string `form:"prefix"`
|
||||
}
|
||||
|
||||
type DeleteAssetRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
16
internal/pkg/dto/request/auth_request.go
Normal file
16
internal/pkg/dto/request/auth_request.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package request
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email *string `json:"email"`
|
||||
}
|
||||
|
||||
type OIDCExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
34
internal/pkg/dto/request/channel_request.go
Normal file
34
internal/pkg/dto/request/channel_request.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package request
|
||||
|
||||
type CreateChannelRequest struct {
|
||||
ChannelType string `json:"channelType"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateChannelRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateChannelRequest
|
||||
}
|
||||
|
||||
type UpdateChannelStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type RollbackChannelAIAgentRolloutRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type DeleteChannelRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type ResetChannelUserTokenSecretRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
21
internal/pkg/dto/request/company_request.go
Normal file
21
internal/pkg/dto/request/company_request.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package request
|
||||
|
||||
type CreateCompanyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateCompanyRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateCompanyRequest
|
||||
}
|
||||
|
||||
type DeleteCompanyRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateCompanyStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
67
internal/pkg/dto/request/conversation_request.go
Normal file
67
internal/pkg/dto/request/conversation_request.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package request
|
||||
|
||||
type AgentConversationFilter string
|
||||
|
||||
const (
|
||||
AgentConversationFilterAIServing AgentConversationFilter = "ai_serving"
|
||||
AgentConversationFilterMine AgentConversationFilter = "mine"
|
||||
AgentConversationFilterActive AgentConversationFilter = "active"
|
||||
AgentConversationFilterPending AgentConversationFilter = "pending"
|
||||
AgentConversationFilterClosed AgentConversationFilter = "closed"
|
||||
)
|
||||
|
||||
type ConversationListRequest struct {
|
||||
Status int `json:"status"`
|
||||
ServiceMode int `json:"serviceMode"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
Keyword string `json:"keyword"`
|
||||
TagID int64 `json:"tagId"`
|
||||
}
|
||||
|
||||
type AssignConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AssigneeID int64 `json:"assigneeId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type DispatchConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
}
|
||||
|
||||
type TransferConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
ToUserID int64 `json:"toUserId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type CloseConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
CloseReason string `json:"closeReason"`
|
||||
}
|
||||
|
||||
// CreateCustomerConversationRequest 访客主动新建请求的入参。
|
||||
type CreateCustomerConversationRequest struct {
|
||||
// Subject 是访客填写的请求主题,用于在其请求列表中区分多条会话;留空时由前端用兜底文案。
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
type ReadConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
}
|
||||
|
||||
type AddConversationTagRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
TagID int64 `json:"tagId"`
|
||||
}
|
||||
|
||||
type RemoveConversationTagRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
TagID int64 `json:"tagId"`
|
||||
}
|
||||
|
||||
// LinkConversationCustomerRequest 将客服会话关联到 CRM 客户(并同步访客身份映射)。
|
||||
type LinkConversationCustomerRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
}
|
||||
27
internal/pkg/dto/request/customer_contact_request.go
Normal file
27
internal/pkg/dto/request/customer_contact_request.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package request
|
||||
|
||||
type CreateCustomerContactRequest struct {
|
||||
CustomerID int64 `json:"customerId"`
|
||||
ContactType string `json:"contactType"`
|
||||
ContactValue string `json:"contactValue"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsVerified bool `json:"isVerified"`
|
||||
Source string `json:"source"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateCustomerContactRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ContactType string `json:"contactType"`
|
||||
ContactValue string `json:"contactValue"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsVerified bool `json:"isVerified"`
|
||||
Source string `json:"source"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type DeleteCustomerContactRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
72
internal/pkg/dto/request/customer_request.go
Normal file
72
internal/pkg/dto/request/customer_request.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package request
|
||||
|
||||
// CustomerListRequest 客户分页列表查询(POST /customer/list JSON Body)。
|
||||
type CustomerListRequest struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Status *int `json:"status,omitempty"`
|
||||
Gender *int `json:"gender,omitempty"`
|
||||
CompanyID *int64 `json:"companyId,omitempty"`
|
||||
// Keyword 模糊匹配:客户姓名、主手机号、主邮箱、任意联系方式(t_customer_contact)、公司名称(t_company)。
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
|
||||
func (r CustomerListRequest) GetPage() int {
|
||||
if r.Page <= 0 {
|
||||
return 1
|
||||
}
|
||||
return r.Page
|
||||
}
|
||||
|
||||
func (r CustomerListRequest) GetLimit() int {
|
||||
if r.Limit <= 0 {
|
||||
return 20
|
||||
}
|
||||
return r.Limit
|
||||
}
|
||||
|
||||
func (r CustomerListRequest) Offset() int {
|
||||
return (r.GetPage() - 1) * r.GetLimit()
|
||||
}
|
||||
|
||||
type CreateCustomerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Gender int `json:"gender"`
|
||||
CompanyID int64 `json:"companyId"`
|
||||
PrimaryMobile string `json:"primaryMobile"`
|
||||
PrimaryEmail string `json:"primaryEmail"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateCustomerRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateCustomerRequest
|
||||
}
|
||||
|
||||
type DeleteCustomerRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateCustomerStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// CustomerProfileContactItem 保存客户档案时的联系方式行(无 id 表示新建)。
|
||||
type CustomerProfileContactItem struct {
|
||||
ID *int64 `json:"id,omitempty"`
|
||||
ContactType string `json:"contactType"`
|
||||
ContactValue string `json:"contactValue"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// SaveCustomerProfileRequest 客户主信息与联系方式一并保存(单事务);id 为空或 0 表示新建客户。
|
||||
type SaveCustomerProfileRequest struct {
|
||||
ID *int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Gender int `json:"gender"`
|
||||
CompanyID int64 `json:"companyId"`
|
||||
Remark string `json:"remark"`
|
||||
Contacts []CustomerProfileContactItem `json:"contacts"`
|
||||
}
|
||||
15
internal/pkg/dto/request/email_request.go
Normal file
15
internal/pkg/dto/request/email_request.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package request
|
||||
|
||||
// EmailRequestRequest is the body of the API that opens a support request on a
|
||||
// visitor's behalf and continues the conversation over email.
|
||||
type EmailRequestRequest struct {
|
||||
// Email is the visitor's address. It becomes their contact identity, so it
|
||||
// must be the address their replies will come from.
|
||||
Email string `json:"email"`
|
||||
// Name is an optional display name.
|
||||
Name string `json:"name"`
|
||||
// Subject is the request title shown in the visitor's request list.
|
||||
Subject string `json:"subject"`
|
||||
// Message is the request body. Required.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
137
internal/pkg/dto/request/knowledge_request.go
Normal file
137
internal/pkg/dto/request/knowledge_request.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type CreateKnowledgeBaseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
KnowledgeType string `json:"knowledgeType"`
|
||||
DefaultTopK int `json:"defaultTopK"`
|
||||
DefaultScoreThreshold float64 `json:"defaultScoreThreshold"`
|
||||
DefaultRerankLimit int `json:"defaultRerankLimit"`
|
||||
ChunkProvider string `json:"chunkProvider"`
|
||||
ChunkTargetTokens int `json:"chunkTargetTokens"`
|
||||
ChunkMaxTokens int `json:"chunkMaxTokens"`
|
||||
ChunkOverlapTokens int `json:"chunkOverlapTokens"`
|
||||
AnswerMode int `json:"answerMode"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateKnowledgeBaseRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateKnowledgeBaseRequest
|
||||
}
|
||||
|
||||
type CreateKnowledgeDirectoryRequest struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateKnowledgeDirectoryRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateKnowledgeDirectoryRequest
|
||||
}
|
||||
|
||||
type DeleteKnowledgeDirectoryRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type CreateKnowledgeDocumentRequest struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
Title string `json:"title"`
|
||||
ContentType enums.KnowledgeDocumentContentType `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type UpdateKnowledgeDocumentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateKnowledgeDocumentRequest
|
||||
}
|
||||
|
||||
type BatchMoveKnowledgeDocumentRequest struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
type BatchDeleteKnowledgeDocumentRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
type CreateKnowledgeFAQRequest struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SimilarQuestions []string `json:"similarQuestions"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateKnowledgeFAQRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateKnowledgeFAQRequest
|
||||
}
|
||||
|
||||
type BatchMoveKnowledgeFAQRequest struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
type BatchDeleteKnowledgeFAQRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
type KnowledgeFAQImportMode string
|
||||
|
||||
const (
|
||||
KnowledgeFAQImportModeAppend KnowledgeFAQImportMode = "append"
|
||||
KnowledgeFAQImportModeOverwrite KnowledgeFAQImportMode = "overwrite"
|
||||
)
|
||||
|
||||
type ImportKnowledgeFAQRequest struct {
|
||||
KnowledgeBaseID int64
|
||||
Mode KnowledgeFAQImportMode
|
||||
Filename string
|
||||
Reader io.Reader
|
||||
Locale string
|
||||
}
|
||||
|
||||
type KnowledgeSearchRequest struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
Question string `json:"question"`
|
||||
TopK int `json:"topK"`
|
||||
ScoreThreshold float64 `json:"scoreThreshold"`
|
||||
RerankLimit int `json:"rerankLimit"`
|
||||
Channel string `json:"channel"`
|
||||
Scene string `json:"scene"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
}
|
||||
|
||||
type KnowledgeAnswerRequest struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
Question string `json:"question"`
|
||||
TopK int `json:"topK"`
|
||||
ScoreThreshold float64 `json:"scoreThreshold"`
|
||||
RerankLimit int `json:"rerankLimit"`
|
||||
Channel string `json:"channel"`
|
||||
Scene string `json:"scene"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AnswerMode int `json:"answerMode"`
|
||||
}
|
||||
|
||||
type CreateKnowledgeFeedbackRequest struct {
|
||||
RetrieveLogID int64 `json:"retrieveLogId"`
|
||||
FeedbackType int `json:"feedbackType"`
|
||||
FeedbackReason string `json:"feedbackReason"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
11
internal/pkg/dto/request/mcp_request.go
Normal file
11
internal/pkg/dto/request/mcp_request.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package request
|
||||
|
||||
type MCPServerDebugRequest struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
}
|
||||
|
||||
type MCPCallToolRequest struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
21
internal/pkg/dto/request/message_request.go
Normal file
21
internal/pkg/dto/request/message_request.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type MessageListRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
SenderType string `json:"senderType"`
|
||||
MessageType string `json:"messageType"`
|
||||
}
|
||||
|
||||
type SendConversationMessageRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
MessageType enums.IMMessageType `json:"messageType"`
|
||||
Content string `json:"content"`
|
||||
Payload string `json:"payload"`
|
||||
ClientMsgID string `json:"clientMsgId"`
|
||||
}
|
||||
|
||||
type RecallConversationMessageRequest struct {
|
||||
MessageID int64 `json:"messageId"`
|
||||
}
|
||||
15
internal/pkg/dto/request/notification_request.go
Normal file
15
internal/pkg/dto/request/notification_request.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package request
|
||||
|
||||
type CreateNotificationRequest struct {
|
||||
RecipientUserID int64 `json:"recipientUserId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
NotificationType string `json:"notificationType"`
|
||||
BizType string `json:"bizType"`
|
||||
BizID int64 `json:"bizId"`
|
||||
ActionURL string `json:"actionUrl"`
|
||||
}
|
||||
|
||||
type MarkNotificationReadRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
5
internal/pkg/dto/request/organization_request.go
Normal file
5
internal/pkg/dto/request/organization_request.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package request
|
||||
|
||||
type OrganizationSwitchRequest struct {
|
||||
OrganizationID int64 `json:"organizationId"`
|
||||
}
|
||||
26
internal/pkg/dto/request/quick_reply_request.go
Normal file
26
internal/pkg/dto/request/quick_reply_request.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type QuickReplyListRequest struct {
|
||||
GroupName string `json:"groupName"`
|
||||
Keyword string `json:"keyword"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type CreateQuickReplyRequest struct {
|
||||
GroupName string `json:"groupName"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status enums.Status `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
}
|
||||
|
||||
type UpdateQuickReplyRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateQuickReplyRequest
|
||||
}
|
||||
|
||||
type DeleteQuickReplyRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
7
internal/pkg/dto/request/satisfaction_request.go
Normal file
7
internal/pkg/dto/request/satisfaction_request.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package request
|
||||
|
||||
type SubmitSatisfactionRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
11
internal/pkg/dto/request/setup_readiness_req.go
Normal file
11
internal/pkg/dto/request/setup_readiness_req.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package request
|
||||
|
||||
type SetupSimulateRouteRequest struct {
|
||||
ChannelID int64 `json:"channel_id" form:"channel_id"`
|
||||
BypassAI bool `json:"bypass_ai" form:"bypass_ai"`
|
||||
}
|
||||
|
||||
type SetupQuickFixRequest struct {
|
||||
Action string `json:"action" form:"action"`
|
||||
EntityID int64 `json:"entity_id" form:"entity_id"`
|
||||
}
|
||||
47
internal/pkg/dto/request/skill_request.go
Normal file
47
internal/pkg/dto/request/skill_request.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package request
|
||||
|
||||
type SkillDefinitionListRequest struct {
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type CreateSkillDefinitionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instruction string `json:"instruction"`
|
||||
Examples []string `json:"examples"`
|
||||
ToolWhitelist []string `json:"toolWhitelist"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateSkillDefinitionRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateSkillDefinitionRequest
|
||||
}
|
||||
|
||||
type DeleteSkillDefinitionRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type RestoreSkillDefinitionRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateSkillDefinitionStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type SkillDebugRunRequest struct {
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
SkillDefinitionID int64 `json:"skillDefinitionId"`
|
||||
UserMessage string `json:"userMessage"`
|
||||
}
|
||||
|
||||
type SkillDebugResumeRequest struct {
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
CheckPointID string `json:"checkPointId"`
|
||||
UserMessage string `json:"userMessage"`
|
||||
}
|
||||
128
internal/pkg/dto/request/support_request.go
Normal file
128
internal/pkg/dto/request/support_request.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package request
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type SupportCustomerRegisterRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SaveDocPageRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Tags []string `json:"tags"`
|
||||
Status enums.DocPageStatus `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateDocPageSettingsRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type SortDocPagesRequest struct {
|
||||
ParentID int64 `json:"parentId"`
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
type ChangeDocPageStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status enums.DocPageStatus `json:"status"`
|
||||
}
|
||||
|
||||
type SaveCategoryRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type CreatePostRequest struct {
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
Title string `json:"title"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type UpdatePostRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
Title string `json:"title"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type ModeratePostRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status enums.PostStatus `json:"status"`
|
||||
}
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
PostID int64 `json:"postId"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type UpdateCommentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ModerateCommentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status enums.CommentStatus `json:"status"`
|
||||
}
|
||||
|
||||
type IDRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type ReactionRequest struct {
|
||||
TargetType enums.ReactionTarget `json:"targetType"`
|
||||
TargetID int64 `json:"targetId"`
|
||||
ReactionType enums.ReactionType `json:"reactionType"`
|
||||
}
|
||||
|
||||
type ReportCommentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type AcceptCommentRequest struct {
|
||||
PostID int64 `json:"postId"`
|
||||
CommentID int64 `json:"commentId"`
|
||||
}
|
||||
|
||||
type DocPageFeedbackRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Helpful bool `json:"helpful"`
|
||||
}
|
||||
|
||||
type DeleteByIDRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type SupportNavigationMenuItemRequest struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
OpenInNewWindow bool `json:"openInNewWindow"`
|
||||
Visible *bool `json:"visible"`
|
||||
Children []SupportNavigationMenuItemRequest `json:"children"`
|
||||
}
|
||||
27
internal/pkg/dto/request/tag_request.go
Normal file
27
internal/pkg/dto/request/tag_request.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package request
|
||||
|
||||
type TagListRequest struct {
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type CreateTagRequest struct {
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
CreateTagRequest
|
||||
}
|
||||
|
||||
type DeleteTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateTagStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
59
internal/pkg/dto/request/ticket_request.go
Normal file
59
internal/pkg/dto/request/ticket_request.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package request
|
||||
|
||||
type CreateTicketRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
}
|
||||
|
||||
type CreateTicketFromConversationRequest struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
}
|
||||
|
||||
type UpdateTicketRequest struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
}
|
||||
|
||||
type LinkTicketCustomerRequest struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
}
|
||||
|
||||
type AssignTicketRequest struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
ToUserID int64 `json:"toUserId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ChangeTicketStatusRequest struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateTicketProgressRequest struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type SaveTicketViewRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Filters map[string]any `json:"filters"`
|
||||
}
|
||||
|
||||
type DeleteTicketViewRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
20
internal/pkg/dto/request/webhook_request.go
Normal file
20
internal/pkg/dto/request/webhook_request.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package request
|
||||
|
||||
type OrgSyncEventData struct {
|
||||
OrgID string `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
UserID string `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserName string `json:"user_name"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
type OrgSyncWebhookRequest struct {
|
||||
Event string `json:"event"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Data OrgSyncEventData `json:"data"`
|
||||
}
|
||||
|
||||
type DOSOrgSyncEventData = OrgSyncEventData
|
||||
type DOSOrgSyncWebhookRequest = OrgSyncWebhookRequest
|
||||
64
internal/pkg/dto/response/admin_response.go
Normal file
64
internal/pkg/dto/response/admin_response.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type PermissionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Type string `json:"type"`
|
||||
GroupName string `json:"groupName"`
|
||||
Method string `json:"method"`
|
||||
ApiPath string `json:"apiPath"`
|
||||
Status enums.Status `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
}
|
||||
|
||||
type PermissionSyncResponse struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
RolePermissionsAdded int `json:"rolePermissionsAdded"`
|
||||
}
|
||||
|
||||
type RoleResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Status enums.Status `json:"status"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
UserType enums.UserType `json:"userType"`
|
||||
Status enums.Status `json:"status"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastLoginIP string `json:"lastLoginIp,omitempty"`
|
||||
Roles []RoleResponse `json:"roles,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// CreateUserResultResponse 创建用户成功响应;password 仅在本次响应中返回一次。
|
||||
type CreateUserResultResponse struct {
|
||||
User *UserResponse `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SessionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
ClientType string `json:"clientType"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
ExpiredAt string `json:"expiredAt"`
|
||||
RevokedAt string `json:"revokedAt"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
18
internal/pkg/dto/response/agent_evaluation_response.go
Normal file
18
internal/pkg/dto/response/agent_evaluation_response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
type AgentEvaluationResultResponse struct {
|
||||
CaseID string `json:"caseId"`
|
||||
Category string `json:"category"`
|
||||
Passed bool `json:"passed"`
|
||||
ReplyText string `json:"replyText"`
|
||||
Interrupted bool `json:"interrupted"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Finding string `json:"finding,omitempty"`
|
||||
}
|
||||
|
||||
type AgentEvaluationReportResponse struct {
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Results []AgentEvaluationResultResponse `json:"results"`
|
||||
CSV string `json:"csv"`
|
||||
}
|
||||
65
internal/pkg/dto/response/agent_response.go
Normal file
65
internal/pkg/dto/response/agent_response.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AgentProfileResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
AgentCode string `json:"agentCode"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Avatar string `json:"avatar"`
|
||||
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
|
||||
MaxConcurrentCount int `json:"maxConcurrentCount"`
|
||||
PriorityLevel int `json:"priorityLevel"`
|
||||
AutoAssignEnabled bool `json:"autoAssignEnabled"`
|
||||
ReceiveOfflineMessage bool `json:"receiveOfflineMessage"`
|
||||
LastOnlineAt string `json:"lastOnlineAt,omitempty"`
|
||||
LastStatusAt string `json:"lastStatusAt,omitempty"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type AgentTeamResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LeaderUserID int64 `json:"leaderUserId"`
|
||||
LeaderUsername string `json:"leaderUsername,omitempty"`
|
||||
LeaderNickname string `json:"leaderNickname,omitempty"`
|
||||
Status enums.Status `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
StartAt string `json:"startAt"`
|
||||
EndAt string `json:"endAt"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchPreviewResponse struct {
|
||||
Total int `json:"total"`
|
||||
Conflict bool `json:"conflict"`
|
||||
Items []AgentTeamScheduleBatchPreviewItem `json:"items"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchPreviewItem struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamName string `json:"teamName"`
|
||||
Date string `json:"date"`
|
||||
Weekday int `json:"weekday"`
|
||||
StartAt string `json:"startAt"`
|
||||
EndAt string `json:"endAt"`
|
||||
Remark string `json:"remark"`
|
||||
Conflict bool `json:"conflict"`
|
||||
ConflictReason string `json:"conflictReason"`
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchGenerateResponse struct {
|
||||
Created int `json:"created"`
|
||||
}
|
||||
27
internal/pkg/dto/response/agent_response_test.go
Normal file
27
internal/pkg/dto/response/agent_response_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentTeamScheduleResponseOmitsSourceType(t *testing.T) {
|
||||
payload, err := json.Marshal(AgentTeamScheduleResponse{
|
||||
ID: 1,
|
||||
TeamID: 2,
|
||||
StartAt: "2026-04-29 09:00:00",
|
||||
EndAt: "2026-04-29 18:00:00",
|
||||
Remark: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response error = %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal response error = %v", err)
|
||||
}
|
||||
if _, ok := decoded["sourceType"]; ok {
|
||||
t.Fatalf("sourceType should not be exposed: %s", payload)
|
||||
}
|
||||
}
|
||||
65
internal/pkg/dto/response/agent_run_response.go
Normal file
65
internal/pkg/dto/response/agent_run_response.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AgentRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AgentRevisionID int64 `json:"agentRevisionId"`
|
||||
SourceMessageID int64 `json:"sourceMessageId"`
|
||||
WorkflowRunID int64 `json:"workflowRunId"`
|
||||
Status string `json:"status"`
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
TraceData string `json:"traceData"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Steps []AgentStepResponse `json:"steps,omitempty"`
|
||||
ToolCalls []AgentToolCallResponse `json:"toolCalls,omitempty"`
|
||||
QualityFeedback *AgentRunQualityFeedbackResponse `json:"qualityFeedback,omitempty"`
|
||||
}
|
||||
|
||||
type AgentRunQualityFeedbackResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentRunID int64 `json:"agentRunId"`
|
||||
ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"`
|
||||
EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"`
|
||||
Comment string `json:"comment"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AgentStepResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentRunID int64 `json:"agentRunId"`
|
||||
WorkflowRunID int64 `json:"workflowRunId"`
|
||||
StepType string `json:"stepType"`
|
||||
StepCode string `json:"stepCode"`
|
||||
Status string `json:"status"`
|
||||
InputPreview string `json:"inputPreview"`
|
||||
OutputPreview string `json:"outputPreview"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationMS int `json:"durationMs"`
|
||||
}
|
||||
|
||||
type AgentToolCallResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentRunID int64 `json:"agentRunId"`
|
||||
AgentStepID int64 `json:"agentStepId"`
|
||||
ToolCode string `json:"toolCode"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RequireConfirm bool `json:"requireConfirm"`
|
||||
Status string `json:"status"`
|
||||
ArgumentsPreview string `json:"argumentsPreview"`
|
||||
ResultPreview string `json:"resultPreview"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
DurationMS int `json:"durationMs"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
9
internal/pkg/dto/response/ai_copilot_response.go
Normal file
9
internal/pkg/dto/response/ai_copilot_response.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package response
|
||||
|
||||
type SuggestReplyResponse struct {
|
||||
Suggestion string `json:"suggestion"`
|
||||
}
|
||||
|
||||
type ConversationSummaryResponse struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
133
internal/pkg/dto/response/ai_response.go
Normal file
133
internal/pkg/dto/response/ai_response.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type AIAgentTeamResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type AIAgentSkillResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type AIAgentMCPToolResponse struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RequireConfirmation bool `json:"requireConfirmation"`
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
|
||||
type AIAgentWorkflowBindingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AgentRevisionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID int64 `json:"agentId"`
|
||||
Revision int `json:"revision"`
|
||||
Status enums.Status `json:"status"`
|
||||
DefinitionHash string `json:"definitionHash"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
PublishedByID int64 `json:"publishedById"`
|
||||
PublishedByName string `json:"publishedByName"`
|
||||
}
|
||||
|
||||
type AIConfigResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider enums.AIProvider `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
ModelType enums.AIModelType `json:"modelType"`
|
||||
ModelName string `json:"modelName"`
|
||||
Dimension int `json:"dimension"`
|
||||
MaxContextTokens int `json:"maxContextTokens"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
MaxRetryCount int `json:"maxRetryCount"`
|
||||
RPMLimit int `json:"rpmLimit"`
|
||||
TPMLimit int `json:"tpmLimit"`
|
||||
Status enums.Status `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse {
|
||||
return AIConfigResponse{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Provider: item.Provider,
|
||||
BaseURL: item.BaseURL,
|
||||
HasAPIKey: item.APIKey != "",
|
||||
ModelType: item.ModelType,
|
||||
ModelName: item.ModelName,
|
||||
Dimension: item.Dimension,
|
||||
MaxContextTokens: item.MaxContextTokens,
|
||||
MaxOutputTokens: item.MaxOutputTokens,
|
||||
TimeoutMS: item.TimeoutMS,
|
||||
MaxRetryCount: item.MaxRetryCount,
|
||||
RPMLimit: item.RPMLimit,
|
||||
TPMLimit: item.TPMLimit,
|
||||
Status: item.Status,
|
||||
SortNo: item.SortNo,
|
||||
Remark: item.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
type AIAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
AIConfigName string `json:"aiConfigName"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
ServiceModeName string `json:"serviceModeName"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
PreviousRolloutPercent int `json:"previousRolloutPercent"`
|
||||
Teams []AIAgentTeamResponse `json:"teams"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
HandoffModeName string `json:"handoffModeName"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackModeName string `json:"fallbackModeName"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
Skills []AIAgentSkillResponse `json:"skills"`
|
||||
MCPTools []AIAgentMCPToolResponse `json:"mcpTools"`
|
||||
WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"`
|
||||
PublishedRevisionID int64 `json:"publishedRevisionId"`
|
||||
SortNo int `json:"sortNo"`
|
||||
AutoHandoffOnFailure bool `json:"autoHandoffOnFailure"`
|
||||
HandoffMessage string `json:"handoffMessage"`
|
||||
OffHoursMessage string `json:"offHoursMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
30
internal/pkg/dto/response/ai_response_test.go
Normal file
30
internal/pkg/dto/response/ai_response_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
func TestBuildAIConfigResponseOmitsAPIKey(t *testing.T) {
|
||||
payload, err := json.Marshal(BuildAIConfigResponse(&models.AIConfig{
|
||||
ID: 1,
|
||||
Name: "test",
|
||||
APIKey: "sk-secret",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response error = %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal response error = %v", err)
|
||||
}
|
||||
if _, ok := decoded["apiKey"]; ok {
|
||||
t.Fatalf("apiKey should not be exposed: %s", payload)
|
||||
}
|
||||
if got, ok := decoded["hasApiKey"].(bool); !ok || !got {
|
||||
t.Fatalf("hasApiKey = %v, want true: %s", decoded["hasApiKey"], payload)
|
||||
}
|
||||
}
|
||||
111
internal/pkg/dto/response/ai_workflow_response.go
Normal file
111
internal/pkg/dto/response/ai_workflow_response.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
workflowvalidator "agent-desk/internal/ai/workflow/validator"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type AIWorkflowResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
DraftDefinition dsl.Definition `json:"draftDefinition"`
|
||||
PublishedVersionID int64 `json:"publishedVersionId"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type AIWorkflowVersionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
Version int `json:"version"`
|
||||
Status enums.Status `json:"status"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
DefinitionHash string `json:"definitionHash"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
PublishedByID int64 `json:"publishedById"`
|
||||
PublishedByName string `json:"publishedByName"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AIWorkflowValidationResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []workflowvalidator.Error `json:"errors"`
|
||||
}
|
||||
|
||||
type AIWorkflowTemplateResponse struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
}
|
||||
|
||||
type AIWorkflowUsageResponse struct {
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentName string `json:"aiAgentName"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AIWorkflowNodeSpecResponse struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
Executable bool `json:"executable"`
|
||||
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
||||
Interruptible bool `json:"interruptible"`
|
||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||
ConfigSchema any `json:"configSchema,omitempty"`
|
||||
InputSchema []workflowregistry.VariableSpec `json:"inputSchema,omitempty"`
|
||||
OutputSchema []workflowregistry.VariableSpec `json:"outputSchema,omitempty"`
|
||||
DefaultInputs map[string]dsl.Value `json:"defaultInputs,omitempty"`
|
||||
}
|
||||
|
||||
type AIWorkflowRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentName string `json:"aiAgentName"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
InterruptType string `json:"interruptType"`
|
||||
InterruptNodeID string `json:"interruptNodeId"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
type AIWorkflowNodeRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowRunID int64 `json:"workflowRunId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeType string `json:"nodeType"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
InputPreview string `json:"inputPreview"`
|
||||
OutputPreview string `json:"outputPreview"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationMS int `json:"durationMs"`
|
||||
}
|
||||
21
internal/pkg/dto/response/asset_response.go
Normal file
21
internal/pkg/dto/response/asset_response.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AssetResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
Provider enums.AssetProvider `json:"provider"`
|
||||
Filename string `json:"filename"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Status enums.AssetStatus `json:"status"`
|
||||
StorageKey string `json:"storageKey"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserID int64 `json:"createUserId"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserID int64 `json:"updateUserId"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
30
internal/pkg/dto/response/auth_response.go
Normal file
30
internal/pkg/dto/response/auth_response.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type AuthUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email string `json:"email,omitempty"`
|
||||
UserType enums.UserType `json:"userType"`
|
||||
Status enums.Status `json:"status"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
User *AuthUserResponse `json:"user"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type PublicConfigResponse struct {
|
||||
Language string `json:"language"`
|
||||
CompanyName string `json:"companyName,omitempty"`
|
||||
CompanyLogoURL string `json:"companyLogoUrl,omitempty"`
|
||||
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
|
||||
OIDCEnabled bool `json:"oidcEnabled"`
|
||||
}
|
||||
78
internal/pkg/dto/response/channel_response.go
Normal file
78
internal/pkg/dto/response/channel_response.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type ChannelResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ChannelType string `json:"channelType"`
|
||||
ChannelID string `json:"channelId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
|
||||
PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"`
|
||||
AIAgentName string `json:"aiAgentName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type ChannelMessageOutboxResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ChannelType string `json:"channelType"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
Payload string `json:"payload"`
|
||||
SendStatus string `json:"sendStatus"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
NextRetryAt string `json:"nextRetryAt"`
|
||||
LastError string `json:"lastError"`
|
||||
SentAt string `json:"sentAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
func BuildChannelResponse(item *models.Channel) ChannelResponse {
|
||||
if item == nil {
|
||||
return ChannelResponse{}
|
||||
}
|
||||
return ChannelResponse{
|
||||
ID: item.ID,
|
||||
ChannelType: item.ChannelType,
|
||||
ChannelID: item.ChannelID,
|
||||
AIAgentID: item.AIAgentID,
|
||||
AIAgentRolloutPercent: item.AIAgentRolloutPercent,
|
||||
PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent,
|
||||
Name: item.Name,
|
||||
ConfigJSON: item.ConfigJSON,
|
||||
Status: item.Status,
|
||||
Remark: item.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildChannelMessageOutboxResponse(item *models.ChannelMessageOutbox) ChannelMessageOutboxResponse {
|
||||
if item == nil {
|
||||
return ChannelMessageOutboxResponse{}
|
||||
}
|
||||
return ChannelMessageOutboxResponse{
|
||||
ID: item.ID,
|
||||
ChannelType: item.ChannelType,
|
||||
ConversationID: item.ConversationID,
|
||||
MessageID: item.MessageID,
|
||||
Payload: item.Payload,
|
||||
SendStatus: item.SendStatus,
|
||||
RetryCount: item.RetryCount,
|
||||
NextRetryAt: utils.FormatTimePtr(item.NextRetryAt),
|
||||
LastError: item.LastError,
|
||||
SentAt: utils.FormatTimePtr(item.SentAt),
|
||||
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||
UpdatedAt: utils.FormatTime(item.UpdatedAt),
|
||||
CreateUserName: item.CreateUserName,
|
||||
UpdateUserName: item.UpdateUserName,
|
||||
}
|
||||
}
|
||||
14
internal/pkg/dto/response/company_response.go
Normal file
14
internal/pkg/dto/response/company_response.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type CompanyResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
CustomerCount int64 `json:"customerCount"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
55
internal/pkg/dto/response/conversation_response.go
Normal file
55
internal/pkg/dto/response/conversation_response.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type ConversationTagResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ConversationParticipantResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ParticipantType string `json:"participantType"`
|
||||
ParticipantID int64 `json:"participantId"`
|
||||
ExternalParticipantID string `json:"externalParticipantId,omitempty"`
|
||||
JoinedAt string `json:"joinedAt,omitempty"`
|
||||
LeftAt string `json:"leftAt,omitempty"`
|
||||
Status enums.Status `json:"status"`
|
||||
}
|
||||
|
||||
type ConversationResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
ChannelID int64 `json:"channelId"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Status enums.IMConversationStatus `json:"status"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
Priority int `json:"priority"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
CurrentAssigneeName string `json:"currentAssigneeName,omitempty"`
|
||||
CurrentTeamID int64 `json:"currentTeamId"`
|
||||
CurrentTeamName string `json:"currentTeamName,omitempty"`
|
||||
LastMessageID int64 `json:"lastMessageId"`
|
||||
LastMessageAt string `json:"lastMessageAt,omitempty"`
|
||||
LastActiveAt string `json:"lastActiveAt,omitempty"`
|
||||
LastMessageSummary string `json:"lastMessageSummary,omitempty"`
|
||||
CustomerUnreadCount int `json:"customerUnreadCount"`
|
||||
AgentUnreadCount int `json:"agentUnreadCount"`
|
||||
CustomerLastReadMessageID int64 `json:"customerLastReadMessageId"`
|
||||
CustomerLastReadAt string `json:"customerLastReadAt,omitempty"`
|
||||
AgentLastReadMessageID int64 `json:"agentLastReadMessageId"`
|
||||
AgentLastReadAt string `json:"agentLastReadAt,omitempty"`
|
||||
CustomerOnline bool `json:"customerOnline"`
|
||||
ClosedAt string `json:"closedAt,omitempty"`
|
||||
ClosedBy int64 `json:"closedBy"`
|
||||
ClosedByName string `json:"closedByName,omitempty"`
|
||||
CloseReason string `json:"closeReason,omitempty"`
|
||||
Satisfaction *ConversationSatisfactionResponse `json:"satisfaction,omitempty"`
|
||||
}
|
||||
|
||||
type ConversationDetailResponse struct {
|
||||
ConversationResponse
|
||||
Participants []ConversationParticipantResponse `json:"participants,omitempty"`
|
||||
}
|
||||
18
internal/pkg/dto/response/customer_contact_response.go
Normal file
18
internal/pkg/dto/response/customer_contact_response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type CustomerContactResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
ContactType enums.ContactType `json:"contactType"`
|
||||
ContactValue string `json:"contactValue"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsVerified bool `json:"isVerified"`
|
||||
VerifiedAt string `json:"verifiedAt,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
18
internal/pkg/dto/response/customer_response.go
Normal file
18
internal/pkg/dto/response/customer_response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type CustomerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Gender enums.Gender `json:"gender"`
|
||||
CompanyID int64 `json:"companyId"`
|
||||
Company *CompanyResponse `json:"company"`
|
||||
LastActiveAt string `json:"lastActiveAt"`
|
||||
PrimaryMobile string `json:"primaryMobile"`
|
||||
PrimaryEmail string `json:"primaryEmail"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
13
internal/pkg/dto/response/customer_session_response.go
Normal file
13
internal/pkg/dto/response/customer_session_response.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package response
|
||||
|
||||
type CustomerSessionCustomerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CustomerSessionExchangeResponse struct {
|
||||
CustomerSessionToken string `json:"customerSessionToken"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
IdentityKey string `json:"identityKey"`
|
||||
Customer CustomerSessionCustomerResponse `json:"customer"`
|
||||
}
|
||||
83
internal/pkg/dto/response/dashboard_response.go
Normal file
83
internal/pkg/dto/response/dashboard_response.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package response
|
||||
|
||||
type DashboardOverviewResponse struct {
|
||||
Range string `json:"range"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Summary DashboardSummaryResponse `json:"summary"`
|
||||
ConversationStats DashboardSectionStatsResponse `json:"conversationStats"`
|
||||
AgentStats DashboardAgentStatsResponse `json:"agentStats"`
|
||||
AIStats DashboardAIStatsResponse `json:"aiStats"`
|
||||
Alerts []DashboardAlertResponse `json:"alerts"`
|
||||
QuickLinks []DashboardQuickLinkResponse `json:"quickLinks"`
|
||||
}
|
||||
|
||||
type DashboardSummaryResponse struct {
|
||||
TodayNewConversations int64 `json:"todayNewConversations"`
|
||||
ProcessingConversations int64 `json:"processingConversations"`
|
||||
PendingDispatchConversations int64 `json:"pendingDispatchConversations"`
|
||||
OnlineAgents int64 `json:"onlineAgents"`
|
||||
AIServiceRate float64 `json:"aiServiceRate"`
|
||||
}
|
||||
|
||||
type DashboardSectionStatsResponse struct {
|
||||
StatusDistribution []DashboardStatusDistributionItem `json:"statusDistribution"`
|
||||
Trend []DashboardTrendItem `json:"trend"`
|
||||
}
|
||||
|
||||
type DashboardStatusDistributionItem struct {
|
||||
Status int `json:"status"`
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type DashboardTrendItem struct {
|
||||
Date string `json:"date"`
|
||||
NewCount int64 `json:"newCount"`
|
||||
ClosedCount int64 `json:"closedCount"`
|
||||
}
|
||||
|
||||
type DashboardAgentStatsResponse struct {
|
||||
OnlineAgents int64 `json:"onlineAgents"`
|
||||
BusyAgents int64 `json:"busyAgents"`
|
||||
OfflineAgents int64 `json:"offlineAgents"`
|
||||
TeamLoads []DashboardTeamLoadResponse `json:"teamLoads"`
|
||||
}
|
||||
|
||||
type DashboardTeamLoadResponse struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamName string `json:"teamName"`
|
||||
TotalAgents int64 `json:"totalAgents"`
|
||||
OnlineAgents int64 `json:"onlineAgents"`
|
||||
BusyAgents int64 `json:"busyAgents"`
|
||||
OfflineAgents int64 `json:"offlineAgents"`
|
||||
WaitingConversations int64 `json:"waitingConversations"`
|
||||
ProcessingConversations int64 `json:"processingConversations"`
|
||||
MaxConcurrentCapacity int64 `json:"maxConcurrentCapacity"`
|
||||
LoadRate float64 `json:"loadRate"`
|
||||
HasScheduleNow bool `json:"hasScheduleNow"`
|
||||
}
|
||||
|
||||
type DashboardAIStatsResponse struct {
|
||||
EnabledAIAgents int64 `json:"enabledAiAgents"`
|
||||
EnabledChannels int64 `json:"enabledChannels"`
|
||||
TodayKnowledgeRetrieves int64 `json:"todayKnowledgeRetrieves"`
|
||||
TodayKnowledgeRetrieveFailCount int64 `json:"todayKnowledgeRetrieveFailCount"`
|
||||
TodayKnowledgeRetrieveFailRate float64 `json:"todayKnowledgeRetrieveFailRate"`
|
||||
TodayAgentRunFailCount int64 `json:"todayAgentRunFailCount"`
|
||||
TodayAIHandoffCount int64 `json:"todayAiHandoffCount"`
|
||||
}
|
||||
|
||||
type DashboardAlertResponse struct {
|
||||
ID string `json:"id"`
|
||||
Level string `json:"level"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Count int64 `json:"count"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type DashboardQuickLinkResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
266
internal/pkg/dto/response/knowledge_response.go
Normal file
266
internal/pkg/dto/response/knowledge_response.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KnowledgeBaseResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
KnowledgeType string `json:"knowledgeType"`
|
||||
KnowledgeTypeName string `json:"knowledgeTypeName"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
DefaultTopK int `json:"defaultTopK"`
|
||||
DefaultScoreThreshold float64 `json:"defaultScoreThreshold"`
|
||||
DefaultRerankLimit int `json:"defaultRerankLimit"`
|
||||
ChunkProvider string `json:"chunkProvider"`
|
||||
ChunkTargetTokens int `json:"chunkTargetTokens"`
|
||||
ChunkMaxTokens int `json:"chunkMaxTokens"`
|
||||
ChunkOverlapTokens int `json:"chunkOverlapTokens"`
|
||||
AnswerMode int `json:"answerMode"`
|
||||
AnswerModeName string `json:"answerModeName"`
|
||||
DocumentCount int64 `json:"documentCount"`
|
||||
FAQCount int64 `json:"faqCount"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type KnowledgeDocumentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
DirectoryName string `json:"directoryName,omitempty"`
|
||||
DirectoryPath string `json:"directoryPath,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ContentType enums.KnowledgeDocumentContentType `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
IndexStatus enums.KnowledgeDocumentIndexStatus `json:"indexStatus"`
|
||||
IndexStatusName string `json:"indexStatusName"`
|
||||
IndexedAt *time.Time `json:"indexedAt"`
|
||||
IndexError string `json:"indexError"`
|
||||
ContentHash string `json:"contentHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type KnowledgeDocumentListResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
DirectoryName string `json:"directoryName,omitempty"`
|
||||
DirectoryPath string `json:"directoryPath,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ContentType enums.KnowledgeDocumentContentType `json:"contentType"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
IndexStatus enums.KnowledgeDocumentIndexStatus `json:"indexStatus"`
|
||||
IndexStatusName string `json:"indexStatusName"`
|
||||
IndexedAt *time.Time `json:"indexedAt"`
|
||||
IndexError string `json:"indexError"`
|
||||
ContentHash string `json:"contentHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type KnowledgeFAQResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"`
|
||||
DirectoryID int64 `json:"directoryId"`
|
||||
DirectoryName string `json:"directoryName,omitempty"`
|
||||
DirectoryPath string `json:"directoryPath,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SimilarQuestions []string `json:"similarQuestions"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
IndexStatus enums.KnowledgeDocumentIndexStatus `json:"indexStatus"`
|
||||
IndexStatusName string `json:"indexStatusName"`
|
||||
IndexedAt *time.Time `json:"indexedAt"`
|
||||
IndexError string `json:"indexError"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type KnowledgeDirectoryResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
Children []KnowledgeDirectoryResponse `json:"children"`
|
||||
}
|
||||
|
||||
type KnowledgeFAQExportedFile struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type KnowledgeFAQImportError struct {
|
||||
Row int `json:"row"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type KnowledgeFAQImportResult struct {
|
||||
Total int `json:"total"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Errors []KnowledgeFAQImportError `json:"errors"`
|
||||
}
|
||||
|
||||
type KnowledgeSearchResult struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ChunkID int64 `json:"chunkId"`
|
||||
DocumentID int64 `json:"documentId"`
|
||||
DocumentTitle string `json:"documentTitle"`
|
||||
FaqID int64 `json:"faqId"`
|
||||
FaqQuestion string `json:"faqQuestion"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
Title string `json:"title"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
RerankScore float64 `json:"rerankScore"`
|
||||
}
|
||||
|
||||
type KnowledgeSearchResponse struct {
|
||||
Question string `json:"question"`
|
||||
Results []KnowledgeSearchResult `json:"results"`
|
||||
HitCount int `json:"hitCount"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
type KnowledgeAnswerResponse struct {
|
||||
Question string `json:"question"`
|
||||
RewriteQuestion string `json:"rewriteQuestion,omitempty"`
|
||||
Answer string `json:"answer"`
|
||||
AnswerStatus int `json:"answerStatus"`
|
||||
AnswerStatusName string `json:"answerStatusName"`
|
||||
Citations []KnowledgeCitation `json:"citations"`
|
||||
Hits []KnowledgeSearchResult `json:"hits"`
|
||||
HitCount int `json:"hitCount"`
|
||||
TopScore float64 `json:"topScore"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
RetrieveMs int64 `json:"retrieveMs"`
|
||||
GenerateMs int64 `json:"generateMs"`
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
ModelName string `json:"modelName"`
|
||||
RetrieveLogID int64 `json:"retrieveLogId"`
|
||||
}
|
||||
|
||||
type KnowledgeCitation struct {
|
||||
DocumentID int64 `json:"documentId"`
|
||||
DocumentTitle string `json:"documentTitle"`
|
||||
FaqID int64 `json:"faqId"`
|
||||
FaqQuestion string `json:"faqQuestion"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
Title string `json:"title"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveLogResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
ChannelName string `json:"channelName"`
|
||||
Scene string `json:"scene"`
|
||||
SceneName string `json:"sceneName"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
RequestID string `json:"requestId"`
|
||||
Question string `json:"question"`
|
||||
RewriteQuestion string `json:"rewriteQuestion"`
|
||||
Answer string `json:"answer"`
|
||||
AnswerStatus int `json:"answerStatus"`
|
||||
AnswerStatusName string `json:"answerStatusName"`
|
||||
HitCount int `json:"hitCount"`
|
||||
TopScore float64 `json:"topScore"`
|
||||
ChunkProvider string `json:"chunkProvider"`
|
||||
ChunkTargetTokens int `json:"chunkTargetTokens"`
|
||||
ChunkMaxTokens int `json:"chunkMaxTokens"`
|
||||
ChunkOverlapTokens int `json:"chunkOverlapTokens"`
|
||||
RerankEnabled bool `json:"rerankEnabled"`
|
||||
RerankLimit int `json:"rerankLimit"`
|
||||
CitationCount int `json:"citationCount"`
|
||||
UsedChunkCount int `json:"usedChunkCount"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
RetrieveMs int64 `json:"retrieveMs"`
|
||||
GenerateMs int64 `json:"generateMs"`
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
ModelName string `json:"modelName"`
|
||||
TraceData string `json:"traceData"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveHitResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
RetrieveLogID int64 `json:"retrieveLogId"`
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ChunkID int64 `json:"chunkId"`
|
||||
DocumentID int64 `json:"documentId"`
|
||||
DocumentTitle string `json:"documentTitle"`
|
||||
FaqID int64 `json:"faqId"`
|
||||
FaqQuestion string `json:"faqQuestion"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
Title string `json:"title"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
ChunkTypeName string `json:"chunkTypeName"`
|
||||
Provider string `json:"provider"`
|
||||
RankNo int `json:"rankNo"`
|
||||
Score float64 `json:"score"`
|
||||
RerankScore float64 `json:"rerankScore"`
|
||||
UsedInAnswer bool `json:"usedInAnswer"`
|
||||
IsCitation bool `json:"isCitation"`
|
||||
Snippet string `json:"snippet"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveLogDetailResponse struct {
|
||||
Log KnowledgeRetrieveLogResponse `json:"log"`
|
||||
Hits []KnowledgeRetrieveHitResponse `json:"hits"`
|
||||
}
|
||||
|
||||
type KnowledgeFeedbackResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
RetrieveLogID int64 `json:"retrieveLogId"`
|
||||
FeedbackType int `json:"feedbackType"`
|
||||
FeedbackTypeName string `json:"feedbackTypeName"`
|
||||
FeedbackReason string `json:"feedbackReason"`
|
||||
UserID int64 `json:"userId"`
|
||||
AgentID int64 `json:"agentId"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
121
internal/pkg/dto/response/mcp_response.go
Normal file
121
internal/pkg/dto/response/mcp_response.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/ai/mcps"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type MCPConnectionResponse struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Protocol string `json:"protocol"`
|
||||
ServerName string `json:"serverName"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func BuildMCPConnectionResponse(item *mcps.ConnectionResult) *MCPConnectionResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &MCPConnectionResponse{
|
||||
ServerCode: item.ServerCode,
|
||||
Endpoint: item.Endpoint,
|
||||
Protocol: item.Protocol,
|
||||
ServerName: item.ServerName,
|
||||
Version: item.Version,
|
||||
}
|
||||
}
|
||||
|
||||
type MCPServerInfoResponse struct {
|
||||
Code string `json:"code"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
func BuildMCPServerInfoResponses(items []mcps.ServerInfo) []MCPServerInfoResponse {
|
||||
ret := make([]MCPServerInfoResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
ret = append(ret, MCPServerInfoResponse{
|
||||
Code: item.Code,
|
||||
Enabled: item.Enabled,
|
||||
Endpoint: item.Endpoint,
|
||||
TimeoutMS: item.TimeoutMS,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type MCPToolInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
InputSchema any `json:"inputSchema"`
|
||||
OutputSchema any `json:"outputSchema,omitempty"`
|
||||
ReadOnlyHint bool `json:"readOnlyHint"`
|
||||
}
|
||||
|
||||
func BuildMCPToolInfoResponses(items []mcps.ToolInfo) []MCPToolInfoResponse {
|
||||
ret := make([]MCPToolInfoResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
ret = append(ret, MCPToolInfoResponse{
|
||||
Name: item.Name,
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
InputSchema: item.InputSchema,
|
||||
OutputSchema: item.OutputSchema,
|
||||
ReadOnlyHint: item.ReadOnlyHint,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type MCPToolCatalogResponse struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
SourceType enums.ToolSourceType `json:"sourceType"`
|
||||
AutoInjected bool `json:"autoInjected"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
InputSchema any `json:"inputSchema"`
|
||||
OutputSchema any `json:"outputSchema,omitempty"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RequireConfirmation bool `json:"requireConfirmation"`
|
||||
RiskEditable bool `json:"riskEditable"`
|
||||
}
|
||||
|
||||
type MCPToolResultContentResponse struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type MCPCallToolResponse struct {
|
||||
ServerCode string `json:"serverCode"`
|
||||
ToolName string `json:"toolName"`
|
||||
IsError bool `json:"isError"`
|
||||
Content []MCPToolResultContentResponse `json:"content"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
}
|
||||
|
||||
func BuildMCPCallToolResponse(item *mcps.ToolCallResult) *MCPCallToolResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
content := make([]MCPToolResultContentResponse, 0, len(item.Content))
|
||||
for _, c := range item.Content {
|
||||
content = append(content, MCPToolResultContentResponse{
|
||||
Type: c.Type,
|
||||
Text: c.Text,
|
||||
Data: c.Data,
|
||||
})
|
||||
}
|
||||
return &MCPCallToolResponse{
|
||||
ServerCode: item.ServerCode,
|
||||
ToolName: item.ToolName,
|
||||
IsError: item.IsError,
|
||||
Content: content,
|
||||
StructuredContent: item.StructuredContent,
|
||||
}
|
||||
}
|
||||
28
internal/pkg/dto/response/message_response.go
Normal file
28
internal/pkg/dto/response/message_response.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type MessageResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
WorkflowRunID int64 `json:"workflowRunId,omitempty"`
|
||||
ClientMsgID string `json:"clientMsgId,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"senderType"`
|
||||
SenderID int64 `json:"senderId"`
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
SenderAvatar string `json:"senderAvatar,omitempty"`
|
||||
MessageType enums.IMMessageType `json:"messageType"`
|
||||
Content string `json:"content"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
SendStatus enums.IMMessageStatus `json:"sendStatus"`
|
||||
SentAt string `json:"sentAt,omitempty"`
|
||||
DeliveredAt string `json:"deliveredAt,omitempty"`
|
||||
ReadAt string `json:"readAt,omitempty"`
|
||||
CustomerRead bool `json:"customerRead"`
|
||||
CustomerReadAt string `json:"customerReadAt,omitempty"`
|
||||
AgentRead bool `json:"agentRead"`
|
||||
AgentReadAt string `json:"agentReadAt,omitempty"`
|
||||
RecalledAt string `json:"recalledAt,omitempty"`
|
||||
QuotedMessageID int64 `json:"quotedMessageId,omitempty"`
|
||||
}
|
||||
18
internal/pkg/dto/response/notification_response.go
Normal file
18
internal/pkg/dto/response/notification_response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
type NotificationResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
RecipientUserID int64 `json:"recipientUserId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
NotificationType string `json:"notificationType"`
|
||||
BizType string `json:"bizType"`
|
||||
BizID int64 `json:"bizId"`
|
||||
ActionURL string `json:"actionUrl"`
|
||||
ReadAt string `json:"readAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationUnreadCountResponse struct {
|
||||
UnreadCount int64 `json:"unreadCount"`
|
||||
}
|
||||
23
internal/pkg/dto/response/organization_response.go
Normal file
23
internal/pkg/dto/response/organization_response.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OrganizationResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo"`
|
||||
Plan string `json:"plan"`
|
||||
Status enums.Status `json:"status"`
|
||||
Role string `json:"role,omitempty"`
|
||||
IsActive bool `json:"isActive"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserOrganizationListResponse struct {
|
||||
CurrentOrganizationID int64 `json:"currentOrganizationId"`
|
||||
Organizations []OrganizationResponse `json:"organizations"`
|
||||
}
|
||||
13
internal/pkg/dto/response/quick_reply_response.go
Normal file
13
internal/pkg/dto/response/quick_reply_response.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type QuickReplyResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
GroupName string `json:"groupName"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status enums.Status `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedBy int64 `json:"createdBy"`
|
||||
}
|
||||
9
internal/pkg/dto/response/satisfaction_response.go
Normal file
9
internal/pkg/dto/response/satisfaction_response.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package response
|
||||
|
||||
type ConversationSatisfactionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
98
internal/pkg/dto/response/setup_readiness_resp.go
Normal file
98
internal/pkg/dto/response/setup_readiness_resp.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package response
|
||||
|
||||
type SetupIssueItem struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID int64 `json:"entity_id"`
|
||||
EntityName string `json:"entity_name"`
|
||||
QuickFixAction string `json:"quick_fix_action,omitempty"`
|
||||
}
|
||||
|
||||
type SetupStepItem struct {
|
||||
Key string `json:"key"`
|
||||
StepNumber int `json:"step_number"`
|
||||
Status string `json:"status"`
|
||||
SummaryCode string `json:"summary_code"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ReadyCount int `json:"ready_count"`
|
||||
Issues []SetupIssueItem `json:"issues"`
|
||||
QuickFixActions []string `json:"quick_fix_actions"`
|
||||
ManagePath string `json:"manage_path"`
|
||||
}
|
||||
|
||||
type CurrentOperatorDiagnostic struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
ProfileStatusOk bool `json:"profile_status_ok"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
TeamActive bool `json:"team_active"`
|
||||
TeamHasScheduleNow bool `json:"team_has_schedule_now"`
|
||||
ServiceStatus int `json:"service_status"`
|
||||
AutoAssignEnabled bool `json:"auto_assign_enabled"`
|
||||
ActiveConversationCount int64 `json:"active_conversation_count"`
|
||||
MaxConcurrentCount int `json:"max_concurrent_count"`
|
||||
CanReceiveNow bool `json:"can_receive_now"`
|
||||
BlockerCodes []string `json:"blocker_codes"`
|
||||
}
|
||||
|
||||
type SetupChannelBrief struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AIAgentID int64 `json:"ai_agent_id"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type SetupReadinessResponse struct {
|
||||
OverallStatus string `json:"overall_status"`
|
||||
ReadySteps int `json:"ready_steps"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
CanReceiveRequestsNow bool `json:"can_receive_requests_now"`
|
||||
Steps []SetupStepItem `json:"steps"`
|
||||
CurrentOperator CurrentOperatorDiagnostic `json:"current_operator"`
|
||||
Channels []SetupChannelBrief `json:"channels"`
|
||||
}
|
||||
|
||||
type SimulatedRouteHop struct {
|
||||
HopKey string `json:"hop_key"`
|
||||
Status string `json:"status"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
EntityID int64 `json:"entity_id,omitempty"`
|
||||
EntityName string `json:"entity_name,omitempty"`
|
||||
QuickFixAction string `json:"quick_fix_action,omitempty"`
|
||||
}
|
||||
|
||||
type SimulatedOperatorCandidate struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
Eligible bool `json:"eligible"`
|
||||
SkipReasonCode string `json:"skip_reason_code"`
|
||||
ActiveCount int64 `json:"active_count"`
|
||||
MaxConcurrentCount int `json:"max_concurrent_count"`
|
||||
ServiceStatus int `json:"service_status"`
|
||||
AutoAssignEnabled bool `json:"auto_assign_enabled"`
|
||||
}
|
||||
|
||||
type SetupSimulateRouteResponse struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
WillReachOperator bool `json:"will_reach_operator"`
|
||||
FinalOutcomeCode string `json:"final_outcome_code"`
|
||||
MatchedOperator *SimulatedOperatorCandidate `json:"matched_operator,omitempty"`
|
||||
Hops []SimulatedRouteHop `json:"hops"`
|
||||
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
|
||||
}
|
||||
|
||||
type SetupQuickFixResponse struct {
|
||||
AppliedActions []string `json:"applied_actions"`
|
||||
Readiness SetupReadinessResponse `json:"readiness"`
|
||||
}
|
||||
34
internal/pkg/dto/response/skill_response.go
Normal file
34
internal/pkg/dto/response/skill_response.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package response
|
||||
|
||||
import "time"
|
||||
|
||||
type SkillDefinitionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instruction string `json:"instruction"`
|
||||
Examples []string `json:"examples"`
|
||||
ToolWhitelist []string `json:"toolWhitelist"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
type SkillDebugRunResponse struct {
|
||||
SkillDefinitionID int64 `json:"skillDefinitionId"`
|
||||
SkillName string `json:"skillName"`
|
||||
ReplyText string `json:"replyText"`
|
||||
ToolWhitelist []string `json:"toolWhitelist"`
|
||||
InvokedToolCodes []string `json:"invokedToolCodes"`
|
||||
InterruptType string `json:"interruptType"`
|
||||
CheckPointID string `json:"checkPointId"`
|
||||
Interrupted bool `json:"interrupted"`
|
||||
TraceData string `json:"traceData"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
}
|
||||
143
internal/pkg/dto/response/support_response.go
Normal file
143
internal/pkg/dto/response/support_response.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type ConfigFieldError struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
MessageKey string `json:"-"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type SupportUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
UserType enums.UserType `json:"userType"`
|
||||
}
|
||||
|
||||
type SupportNavigationMenuItemResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
OpenInNewWindow bool `json:"openInNewWindow"`
|
||||
Visible bool `json:"visible"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Children []SupportNavigationMenuItemResponse `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type PublicSupportConfigResponse struct {
|
||||
NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"`
|
||||
}
|
||||
|
||||
type DashboardSupportConfigResponse struct {
|
||||
NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"`
|
||||
}
|
||||
|
||||
// DashboardBrandingResponse shows both what is saved and what is actually in
|
||||
// effect. They differ whenever configuration supplies a fallback, and an operator
|
||||
// editing this form needs to see which one they are changing.
|
||||
type DashboardBrandingResponse struct {
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyLogoURL string `json:"companyLogoUrl"`
|
||||
// Saved* is what this form would persist; Configured* comes from the file or
|
||||
// environment; Effective* is what every page will render.
|
||||
SavedCompanyName string `json:"savedCompanyName"`
|
||||
SavedCompanyLogoURL string `json:"savedCompanyLogoUrl"`
|
||||
ConfiguredCompanyName string `json:"configuredCompanyName"`
|
||||
ConfiguredCompanyLogo string `json:"configuredCompanyLogoUrl"`
|
||||
EffectiveCompanyName string `json:"effectiveCompanyName"`
|
||||
UsesConfiguredFallback bool `json:"usesConfiguredFallback"`
|
||||
}
|
||||
|
||||
type DocPageResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Tags []string `json:"tags"`
|
||||
Status enums.DocPageStatus `json:"status"`
|
||||
SortNo int `json:"sortNo"`
|
||||
ViewCount int64 `json:"viewCount"`
|
||||
HelpfulCount int64 `json:"helpfulCount"`
|
||||
UnhelpfulCount int64 `json:"unhelpfulCount"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
SyncedKnowledgeDocumentID int64 `json:"syncedKnowledgeDocumentId"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DocPageNavigationResponse is the lightweight public document tree.
|
||||
// Content remains available only from the doc-page detail endpoint.
|
||||
type DocPageNavigationResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Children []*DocPageNavigationResponse `json:"children"`
|
||||
}
|
||||
|
||||
type CategoryResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PostResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserType enums.UserType `json:"userType"`
|
||||
Title string `json:"title"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Status enums.PostStatus `json:"status"`
|
||||
AcceptedCommentID int64 `json:"acceptedCommentId"`
|
||||
CommentCount int64 `json:"commentCount"`
|
||||
ReactionCount int64 `json:"reactionCount"`
|
||||
ViewCount int64 `json:"viewCount"`
|
||||
LastCommentedAt string `json:"lastCommentedAt"`
|
||||
LastCommentUserType enums.CommentAuthorType `json:"lastCommentUserType"`
|
||||
LastCommentUserID int64 `json:"lastCommentUserId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"postId"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
AuthorType enums.CommentAuthorType `json:"authorType"`
|
||||
AuthorID int64 `json:"authorId"`
|
||||
AuthorName string `json:"authorName"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
Status enums.CommentStatus `json:"status"`
|
||||
ReactionCount int64 `json:"reactionCount"`
|
||||
ReplyCount int64 `json:"replyCount"`
|
||||
ReportCount int64 `json:"reportCount"`
|
||||
IsAccepted bool `json:"isAccepted"`
|
||||
Replies []CommentResponse `json:"replies"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PostDetailResponse struct {
|
||||
Post PostResponse `json:"post"`
|
||||
Comments []CommentResponse `json:"comments"`
|
||||
}
|
||||
26
internal/pkg/dto/response/tag_response.go
Normal file
26
internal/pkg/dto/response/tag_response.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type TagResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Status enums.Status `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type TagTreeResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
SortNo int `json:"sortNo"`
|
||||
Status enums.Status `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Children []*TagTreeResponse `json:"children"`
|
||||
}
|
||||
55
internal/pkg/dto/response/ticket_response.go
Normal file
55
internal/pkg/dto/response/ticket_response.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package response
|
||||
|
||||
import "agent-desk/internal/pkg/enums"
|
||||
|
||||
type TicketProgressResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TicketID int64 `json:"ticketId"`
|
||||
Content string `json:"content"`
|
||||
AuthorID int64 `json:"authorId"`
|
||||
AuthorName string `json:"authorName,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type TicketResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TicketNo string `json:"ticketNo"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Source enums.TicketSource `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Tags []TagResponse `json:"tags,omitempty"`
|
||||
Status enums.TicketStatus `json:"status"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||
CurrentAssigneeName string `json:"currentAssigneeName,omitempty"`
|
||||
CreatedBy int64 `json:"createdBy"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
HandledAt string `json:"handledAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
}
|
||||
|
||||
type TicketDetailResponse struct {
|
||||
Ticket TicketResponse `json:"ticket"`
|
||||
Progresses []TicketProgressResponse `json:"progresses,omitempty"`
|
||||
}
|
||||
|
||||
type TicketSummaryResponse struct {
|
||||
All int64 `json:"all"`
|
||||
Pending int64 `json:"pending"`
|
||||
InProgress int64 `json:"inProgress"`
|
||||
Done int64 `json:"done"`
|
||||
Unassigned int64 `json:"unassigned"`
|
||||
Mine int64 `json:"mine"`
|
||||
Stale int64 `json:"stale"`
|
||||
}
|
||||
|
||||
type TicketViewResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Filters map[string]any `json:"filters,omitempty"`
|
||||
SortNo int `json:"sortNo"`
|
||||
}
|
||||
12
internal/pkg/dto/response/widget_response.go
Normal file
12
internal/pkg/dto/response/widget_response.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package response
|
||||
|
||||
type WidgetConfigResponse struct {
|
||||
ChannelID string `json:"channelId"`
|
||||
ChannelType string `json:"channelType"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
ThemeColor string `json:"themeColor"`
|
||||
Position string `json:"position"`
|
||||
Width string `json:"width"`
|
||||
PromptStarters []string `json:"promptStarters,omitempty"`
|
||||
}
|
||||
59
internal/pkg/enums/agent.go
Normal file
59
internal/pkg/enums/agent.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package enums
|
||||
|
||||
type AgentRunResolutionStatus string
|
||||
|
||||
const (
|
||||
AgentRunResolutionStatusUnknown AgentRunResolutionStatus = "unknown"
|
||||
AgentRunResolutionStatusResolved AgentRunResolutionStatus = "resolved"
|
||||
AgentRunResolutionStatusUnresolved AgentRunResolutionStatus = "unresolved"
|
||||
)
|
||||
|
||||
var AgentRunResolutionStatusValues = []AgentRunResolutionStatus{
|
||||
AgentRunResolutionStatusUnknown,
|
||||
AgentRunResolutionStatusResolved,
|
||||
AgentRunResolutionStatusUnresolved,
|
||||
}
|
||||
|
||||
type AgentRunEvidenceStatus string
|
||||
|
||||
const (
|
||||
AgentRunEvidenceStatusUnknown AgentRunEvidenceStatus = "unknown"
|
||||
AgentRunEvidenceStatusSupported AgentRunEvidenceStatus = "supported"
|
||||
AgentRunEvidenceStatusUnsupported AgentRunEvidenceStatus = "unsupported"
|
||||
)
|
||||
|
||||
var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{
|
||||
AgentRunEvidenceStatusUnknown,
|
||||
AgentRunEvidenceStatusSupported,
|
||||
AgentRunEvidenceStatusUnsupported,
|
||||
}
|
||||
|
||||
type ServiceStatus int
|
||||
|
||||
const (
|
||||
ServiceStatusIdle ServiceStatus = 0
|
||||
ServiceStatusBusy ServiceStatus = 1
|
||||
)
|
||||
|
||||
var ServiceStatusValues = []ServiceStatus{
|
||||
ServiceStatusIdle,
|
||||
ServiceStatusBusy,
|
||||
}
|
||||
|
||||
var serviceStatusLabelMap = map[ServiceStatus]string{
|
||||
ServiceStatusIdle: "空闲",
|
||||
ServiceStatusBusy: "忙碌",
|
||||
}
|
||||
|
||||
func IsValidServiceStatus(status ServiceStatus) bool {
|
||||
for _, item := range ServiceStatusValues {
|
||||
if item == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetServiceStatusLabel(status ServiceStatus) string {
|
||||
return serviceStatusLabelMap[status]
|
||||
}
|
||||
33
internal/pkg/enums/ai.go
Normal file
33
internal/pkg/enums/ai.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package enums
|
||||
|
||||
type AIProvider string
|
||||
|
||||
const (
|
||||
AIProviderOpenAI AIProvider = "openai"
|
||||
)
|
||||
|
||||
var aiProviderLabelMap = map[AIProvider]string{
|
||||
AIProviderOpenAI: "OpenAI",
|
||||
}
|
||||
|
||||
func GetAIProviderLabel(provider AIProvider) string {
|
||||
return aiProviderLabelMap[provider]
|
||||
}
|
||||
|
||||
type AIModelType string
|
||||
|
||||
const (
|
||||
AIModelTypeLLM AIModelType = "llm"
|
||||
AIModelTypeEmbedding AIModelType = "embedding"
|
||||
AIModelTypeRerank AIModelType = "rerank"
|
||||
)
|
||||
|
||||
var aiModelTypeLabelMap = map[AIModelType]string{
|
||||
AIModelTypeLLM: "大语言模型",
|
||||
AIModelTypeEmbedding: "向量模型",
|
||||
AIModelTypeRerank: "重排序模型",
|
||||
}
|
||||
|
||||
func GetAIModelTypeLabel(modelType AIModelType) string {
|
||||
return aiModelTypeLabelMap[modelType]
|
||||
}
|
||||
37
internal/pkg/enums/asset.go
Normal file
37
internal/pkg/enums/asset.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package enums
|
||||
|
||||
type AssetStatus int
|
||||
|
||||
const (
|
||||
AssetStatusPending AssetStatus = 1
|
||||
AssetStatusSuccess AssetStatus = 2
|
||||
AssetStatusFailed AssetStatus = 3
|
||||
AssetStatusDeleted AssetStatus = 4
|
||||
)
|
||||
|
||||
var assetStatusLabelMap = map[AssetStatus]string{
|
||||
AssetStatusPending: "处理中",
|
||||
AssetStatusSuccess: "成功",
|
||||
AssetStatusFailed: "失败",
|
||||
AssetStatusDeleted: "已删除",
|
||||
}
|
||||
|
||||
func GetAssetStatusLabel(status AssetStatus) string {
|
||||
return assetStatusLabelMap[status]
|
||||
}
|
||||
|
||||
type AssetProvider string
|
||||
|
||||
const (
|
||||
AssetProviderLocal AssetProvider = "local"
|
||||
AssetProviderOSS AssetProvider = "oss"
|
||||
)
|
||||
|
||||
var assetProviderLabelMap = map[AssetProvider]string{
|
||||
AssetProviderLocal: "本地存储",
|
||||
AssetProviderOSS: "对象存储",
|
||||
}
|
||||
|
||||
func GetAssetProviderLabel(provider AssetProvider) string {
|
||||
return assetProviderLabelMap[provider]
|
||||
}
|
||||
20
internal/pkg/enums/channel_type.go
Normal file
20
internal/pkg/enums/channel_type.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package enums
|
||||
|
||||
type ChannelMessageOutboxStatus string
|
||||
|
||||
const (
|
||||
ChannelMessageOutboxStatusPending ChannelMessageOutboxStatus = "pending"
|
||||
ChannelMessageOutboxStatusSending ChannelMessageOutboxStatus = "sending"
|
||||
ChannelMessageOutboxStatusSent ChannelMessageOutboxStatus = "sent"
|
||||
ChannelMessageOutboxStatusFailed ChannelMessageOutboxStatus = "failed"
|
||||
ChannelMessageOutboxStatusIgnored ChannelMessageOutboxStatus = "ignored"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelTypeWeb = "web"
|
||||
ChannelTypeTelegram = "telegram"
|
||||
ChannelTypeZaloOA = "zalo_oa"
|
||||
ChannelTypeSlack = "slack"
|
||||
ChannelTypeDiscord = "discord"
|
||||
ChannelTypeEmail = "email"
|
||||
)
|
||||
36
internal/pkg/enums/customer.go
Normal file
36
internal/pkg/enums/customer.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package enums
|
||||
|
||||
const CustomerSourceTypeWebChat = ExternalSourceGuest
|
||||
|
||||
func GetCustomerSourceTypeLabel(sourceType ExternalSource) string {
|
||||
return GetExternalSourceLabel(ExternalSource(sourceType))
|
||||
}
|
||||
|
||||
// 联系方式类型
|
||||
type ContactType string
|
||||
|
||||
const (
|
||||
ContactTypeMobile ContactType = "mobile"
|
||||
ContactTypeEmail ContactType = "email"
|
||||
ContactTypeOther ContactType = "other"
|
||||
)
|
||||
|
||||
var contactTypeLabelMap = map[ContactType]string{
|
||||
ContactTypeMobile: "手机号",
|
||||
ContactTypeEmail: "邮箱",
|
||||
ContactTypeOther: "其他",
|
||||
}
|
||||
|
||||
func GetContactTypeLabel(contactType ContactType) string {
|
||||
return contactTypeLabelMap[contactType]
|
||||
}
|
||||
|
||||
// IsValidContactType 判断字符串是否为合法联系方式类型。
|
||||
func IsValidContactType(v string) bool {
|
||||
switch ContactType(v) {
|
||||
case ContactTypeMobile, ContactTypeEmail, ContactTypeOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
84
internal/pkg/enums/enums.go
Normal file
84
internal/pkg/enums/enums.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package enums
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusOk Status = 0
|
||||
StatusDisabled Status = 1
|
||||
StatusDeleted Status = 2
|
||||
)
|
||||
|
||||
var StatusValues = []Status{StatusOk, StatusDisabled, StatusDeleted}
|
||||
|
||||
var statusLabelMap = map[Status]string{
|
||||
StatusOk: "启用",
|
||||
StatusDisabled: "禁用",
|
||||
StatusDeleted: "已删除",
|
||||
}
|
||||
|
||||
func GetStatusLabel(status Status) string {
|
||||
return statusLabelMap[status]
|
||||
}
|
||||
|
||||
func IsValidStatus(status int) bool {
|
||||
for _, s := range StatusValues {
|
||||
if int(s) == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Gender int
|
||||
|
||||
const (
|
||||
GenderUnknown Gender = 0
|
||||
GenderMale Gender = 1
|
||||
GenderFemale Gender = 2
|
||||
)
|
||||
|
||||
var GenderValues = []Gender{GenderUnknown, GenderMale, GenderFemale}
|
||||
|
||||
var genderLabelMap = map[Gender]string{
|
||||
GenderUnknown: "未知",
|
||||
GenderMale: "男",
|
||||
GenderFemale: "女",
|
||||
}
|
||||
|
||||
func GetGenderLabel(gender Gender) string {
|
||||
return genderLabelMap[gender]
|
||||
}
|
||||
|
||||
func IsValidGender(gender int) bool {
|
||||
for _, g := range GenderValues {
|
||||
if int(g) == gender {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ThirdProvider string
|
||||
|
||||
const (
|
||||
ThirdProviderOIDC ThirdProvider = "oidc"
|
||||
)
|
||||
|
||||
var ThirdProviderValues = []ThirdProvider{ThirdProviderOIDC}
|
||||
|
||||
var thirdProviderLabelMap = map[ThirdProvider]string{
|
||||
ThirdProviderOIDC: "OIDC",
|
||||
}
|
||||
|
||||
func GetThirdProviderLabel(provider ThirdProvider) string {
|
||||
return thirdProviderLabelMap[provider]
|
||||
}
|
||||
|
||||
func IsValidThirdProvider(provider string) bool {
|
||||
for _, p := range ThirdProviderValues {
|
||||
if string(p) == provider {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
33
internal/pkg/enums/external_identity.go
Normal file
33
internal/pkg/enums/external_identity.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package enums
|
||||
|
||||
// ExternalSource 外部身份来源。
|
||||
//
|
||||
// 与 ExternalID 组合即可唯一标识某渠道下的访客身份。
|
||||
type ExternalSource string
|
||||
|
||||
const (
|
||||
ExternalSourceGuest ExternalSource = "guest" // 访客
|
||||
ExternalSourceUser ExternalSource = "user" // 用户信息
|
||||
ExternalSourceTelegram ExternalSource = "telegram" // Telegram
|
||||
ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA
|
||||
ExternalSourceSlack ExternalSource = "slack" // Slack
|
||||
ExternalSourceDiscord ExternalSource = "discord" // Discord
|
||||
ExternalSourceEmail ExternalSource = "email" // Email
|
||||
)
|
||||
|
||||
var externalSourceLabelMap = map[ExternalSource]string{
|
||||
ExternalSourceGuest: "访客",
|
||||
ExternalSourceUser: "用户",
|
||||
ExternalSourceTelegram: "Telegram",
|
||||
ExternalSourceZaloOA: "Zalo OA",
|
||||
ExternalSourceSlack: "Slack",
|
||||
ExternalSourceDiscord: "Discord",
|
||||
ExternalSourceEmail: "Email",
|
||||
}
|
||||
|
||||
func GetExternalSourceLabel(v ExternalSource) string {
|
||||
if s, ok := externalSourceLabelMap[v]; ok {
|
||||
return s
|
||||
}
|
||||
return string(v)
|
||||
}
|
||||
274
internal/pkg/enums/im.go
Normal file
274
internal/pkg/enums/im.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package enums
|
||||
|
||||
type IMConversationStatus int
|
||||
|
||||
const (
|
||||
IMConversationStatusAIServing IMConversationStatus = 1
|
||||
IMConversationStatusPending IMConversationStatus = 2
|
||||
IMConversationStatusActive IMConversationStatus = 3
|
||||
IMConversationStatusClosed IMConversationStatus = 4
|
||||
)
|
||||
|
||||
var imConversationStatusLabelMap = map[IMConversationStatus]string{
|
||||
IMConversationStatusAIServing: "AI接待中",
|
||||
IMConversationStatusPending: "待接入",
|
||||
IMConversationStatusActive: "处理中",
|
||||
IMConversationStatusClosed: "已关闭",
|
||||
}
|
||||
|
||||
var IMConversationStatusValues = []IMConversationStatus{
|
||||
IMConversationStatusAIServing,
|
||||
IMConversationStatusPending,
|
||||
IMConversationStatusActive,
|
||||
IMConversationStatusClosed,
|
||||
}
|
||||
|
||||
func GetIMConversationStatusLabel(status IMConversationStatus) string {
|
||||
return imConversationStatusLabelMap[status]
|
||||
}
|
||||
|
||||
type IMConversationServiceMode int
|
||||
|
||||
const (
|
||||
IMConversationServiceModeAIOnly IMConversationServiceMode = 1
|
||||
IMConversationServiceModeHumanOnly IMConversationServiceMode = 2
|
||||
IMConversationServiceModeAIFirst IMConversationServiceMode = 3
|
||||
)
|
||||
|
||||
var IMConversationServiceModeValues = []IMConversationServiceMode{
|
||||
IMConversationServiceModeAIOnly,
|
||||
IMConversationServiceModeHumanOnly,
|
||||
IMConversationServiceModeAIFirst,
|
||||
}
|
||||
|
||||
var imConversationServiceModeLabelMap = map[IMConversationServiceMode]string{
|
||||
IMConversationServiceModeAIOnly: "仅AI",
|
||||
IMConversationServiceModeHumanOnly: "仅人工",
|
||||
IMConversationServiceModeAIFirst: "AI优先",
|
||||
}
|
||||
|
||||
func GetIMConversationServiceModeLabel(mode IMConversationServiceMode) string {
|
||||
return imConversationServiceModeLabelMap[mode]
|
||||
}
|
||||
|
||||
type IMSenderType string
|
||||
|
||||
const (
|
||||
IMSenderTypeAgent IMSenderType = "agent" // 客服
|
||||
IMSenderTypeCustomer IMSenderType = "customer" // 客户
|
||||
IMSenderTypeAI IMSenderType = "ai" // AI
|
||||
IMSenderTypeSystem IMSenderType = "system" // 系统
|
||||
)
|
||||
|
||||
var imSenderTypeLabelMap = map[IMSenderType]string{
|
||||
IMSenderTypeAgent: "客服",
|
||||
IMSenderTypeCustomer: "客户",
|
||||
IMSenderTypeAI: "AI",
|
||||
IMSenderTypeSystem: "系统",
|
||||
}
|
||||
|
||||
func GetIMSenderTypeLabel(senderType IMSenderType) string {
|
||||
return imSenderTypeLabelMap[senderType]
|
||||
}
|
||||
|
||||
type IMParticipantType string
|
||||
|
||||
const (
|
||||
IMParticipantTypeCustomer IMParticipantType = "customer"
|
||||
IMParticipantTypeAgent IMParticipantType = "agent"
|
||||
IMParticipantTypeAI IMParticipantType = "ai"
|
||||
IMParticipantTypeSystem IMParticipantType = "system"
|
||||
)
|
||||
|
||||
var imParticipantTypeLabelMap = map[IMParticipantType]string{
|
||||
IMParticipantTypeCustomer: "客户",
|
||||
IMParticipantTypeAgent: "客服",
|
||||
IMParticipantTypeAI: "AI",
|
||||
IMParticipantTypeSystem: "系统",
|
||||
}
|
||||
|
||||
func GetIMParticipantTypeLabel(participantType IMParticipantType) string {
|
||||
return imParticipantTypeLabelMap[participantType]
|
||||
}
|
||||
|
||||
type IMAssignmentType string
|
||||
|
||||
const (
|
||||
IMAssignmentTypeAssign IMAssignmentType = "assign"
|
||||
IMAssignmentTypeTransfer IMAssignmentType = "transfer"
|
||||
)
|
||||
|
||||
var imAssignmentTypeLabelMap = map[IMAssignmentType]string{
|
||||
IMAssignmentTypeAssign: "分配",
|
||||
IMAssignmentTypeTransfer: "转接",
|
||||
}
|
||||
|
||||
func GetIMAssignmentTypeLabel(assignType IMAssignmentType) string {
|
||||
return imAssignmentTypeLabelMap[assignType]
|
||||
}
|
||||
|
||||
type IMAssignmentStatus int
|
||||
|
||||
const (
|
||||
IMAssignmentStatusActive IMAssignmentStatus = 0
|
||||
IMAssignmentStatusInactive IMAssignmentStatus = 1
|
||||
)
|
||||
|
||||
var imAssignmentStatusLabelMap = map[IMAssignmentStatus]string{
|
||||
IMAssignmentStatusActive: "进行中",
|
||||
IMAssignmentStatusInactive: "已结束",
|
||||
}
|
||||
|
||||
func GetIMAssignmentStatusLabel(status IMAssignmentStatus) string {
|
||||
return imAssignmentStatusLabelMap[status]
|
||||
}
|
||||
|
||||
type IMMessageType string
|
||||
|
||||
const (
|
||||
IMMessageTypeText IMMessageType = "text"
|
||||
IMMessageTypeImage IMMessageType = "image"
|
||||
IMMessageTypeAttachment IMMessageType = "attachment"
|
||||
IMMessageTypeHTML IMMessageType = "html"
|
||||
)
|
||||
|
||||
var imMessageTypeLabelMap = map[IMMessageType]string{
|
||||
IMMessageTypeText: "文本",
|
||||
IMMessageTypeImage: "图片",
|
||||
IMMessageTypeAttachment: "附件",
|
||||
IMMessageTypeHTML: "富文本",
|
||||
}
|
||||
|
||||
func GetIMMessageTypeLabel(messageType IMMessageType) string {
|
||||
return imMessageTypeLabelMap[messageType]
|
||||
}
|
||||
|
||||
type IMMessageStatus int
|
||||
|
||||
const (
|
||||
IMMessageStatusSending IMMessageStatus = 1
|
||||
IMMessageStatusSent IMMessageStatus = 2
|
||||
IMMessageStatusDelivered IMMessageStatus = 3
|
||||
IMMessageStatusRead IMMessageStatus = 4
|
||||
IMMessageStatusFailed IMMessageStatus = 5
|
||||
IMMessageStatusRecalled IMMessageStatus = 6
|
||||
)
|
||||
|
||||
var imMessageStatusLabelMap = map[IMMessageStatus]string{
|
||||
IMMessageStatusSending: "发送中",
|
||||
IMMessageStatusSent: "已发送",
|
||||
IMMessageStatusDelivered: "已送达",
|
||||
IMMessageStatusRead: "已读",
|
||||
IMMessageStatusFailed: "发送失败",
|
||||
IMMessageStatusRecalled: "已撤回",
|
||||
}
|
||||
|
||||
func GetIMMessageStatusLabel(status IMMessageStatus) string {
|
||||
return imMessageStatusLabelMap[status]
|
||||
}
|
||||
|
||||
type IMEventType string
|
||||
|
||||
const (
|
||||
IMEventTypeCreate IMEventType = "create"
|
||||
IMEventTypeAssign IMEventType = "assign"
|
||||
IMEventTypeTransfer IMEventType = "transfer"
|
||||
IMEventTypeClose IMEventType = "close"
|
||||
IMEventTypeMessageSend IMEventType = "message_send"
|
||||
IMEventTypeMessageRecall IMEventType = "message_recall"
|
||||
)
|
||||
|
||||
var imEventTypeLabelMap = map[IMEventType]string{
|
||||
IMEventTypeCreate: "创建会话",
|
||||
IMEventTypeAssign: "分配会话",
|
||||
IMEventTypeTransfer: "转接会话",
|
||||
IMEventTypeClose: "关闭会话",
|
||||
IMEventTypeMessageSend: "发送消息",
|
||||
IMEventTypeMessageRecall: "撤回消息",
|
||||
}
|
||||
|
||||
func GetIMEventTypeLabel(eventType IMEventType) string {
|
||||
return imEventTypeLabelMap[eventType]
|
||||
}
|
||||
|
||||
type AIAgentHandoffMode int
|
||||
|
||||
const (
|
||||
AIAgentHandoffModeWaitPool AIAgentHandoffMode = 1
|
||||
AIAgentHandoffModeDefaultTeamPool AIAgentHandoffMode = 2
|
||||
AIAgentHandoffModeAIHoldAndNotify AIAgentHandoffMode = 3
|
||||
)
|
||||
|
||||
var AIAgentHandoffModeValues = []AIAgentHandoffMode{
|
||||
AIAgentHandoffModeWaitPool,
|
||||
AIAgentHandoffModeDefaultTeamPool,
|
||||
AIAgentHandoffModeAIHoldAndNotify,
|
||||
}
|
||||
|
||||
var aiAgentHandoffModeLabelMap = map[AIAgentHandoffMode]string{
|
||||
AIAgentHandoffModeWaitPool: "进入待接入池",
|
||||
AIAgentHandoffModeDefaultTeamPool: "进入默认客服组待接入池",
|
||||
AIAgentHandoffModeAIHoldAndNotify: "AI继续接待并提醒人工",
|
||||
}
|
||||
|
||||
func GetAIAgentHandoffModeLabel(mode AIAgentHandoffMode) string {
|
||||
return aiAgentHandoffModeLabelMap[mode]
|
||||
}
|
||||
|
||||
type AIAgentFallbackMode int
|
||||
|
||||
const (
|
||||
AIAgentFallbackModeNoAnswer AIAgentFallbackMode = 1
|
||||
AIAgentFallbackModeSuggestRetry AIAgentFallbackMode = 2
|
||||
AIAgentFallbackModeHandoff AIAgentFallbackMode = 3
|
||||
)
|
||||
|
||||
var AIAgentFallbackModeValues = []AIAgentFallbackMode{
|
||||
AIAgentFallbackModeNoAnswer,
|
||||
AIAgentFallbackModeSuggestRetry,
|
||||
AIAgentFallbackModeHandoff,
|
||||
}
|
||||
|
||||
var aiAgentFallbackModeLabelMap = map[AIAgentFallbackMode]string{
|
||||
AIAgentFallbackModeNoAnswer: "直接说明知识不足",
|
||||
AIAgentFallbackModeSuggestRetry: "引导用户补充信息",
|
||||
AIAgentFallbackModeHandoff: "转人工客服",
|
||||
}
|
||||
|
||||
func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string {
|
||||
return aiAgentFallbackModeLabelMap[mode]
|
||||
}
|
||||
|
||||
const (
|
||||
IMRealtimeEventConnected = "connected"
|
||||
IMRealtimeEventPong = "pong"
|
||||
IMRealtimeEventSubscribed = "subscribed"
|
||||
IMRealtimeEventUnsubscribed = "unsubscribed"
|
||||
IMRealtimeEventResyncRequired = "resyncRequired"
|
||||
IMRealtimeEventMessageCreated = "message.created"
|
||||
IMRealtimeEventMessageRecalled = "message.recalled"
|
||||
IMRealtimeEventConversationCreated = "conversation.created"
|
||||
IMRealtimeEventConversationUpdated = "conversation.updated"
|
||||
IMRealtimeEventConversationAssigned = "conversation.assigned"
|
||||
IMRealtimeEventConversationTransferred = "conversation.transferred"
|
||||
IMRealtimeEventConversationClosed = "conversation.closed"
|
||||
IMRealtimeEventConversationRead = "conversation.read"
|
||||
IMRealtimeEventNotificationCreated = "notification.created"
|
||||
IMRealtimeEventCustomerSessionRefresh = "customer_session.refresh"
|
||||
IMRealtimeEventAgentActivity = "agent.activity"
|
||||
IMRealtimeEventOperatorPresence = "operator.presence"
|
||||
)
|
||||
|
||||
const (
|
||||
IMRealtimeClientTypePing = "ping"
|
||||
IMRealtimeClientTypeSubscribe = "subscribe"
|
||||
IMRealtimeClientTypeUnsubscribe = "unsubscribe"
|
||||
IMRealtimeClientTypeAck = "ack"
|
||||
IMRealtimeClientTypePresence = "presence"
|
||||
)
|
||||
|
||||
const (
|
||||
IMRealtimeResyncReasonInvalidPayload = "invalid_payload"
|
||||
IMRealtimeResyncReasonUnsupportedMessageType = "unsupported_message_type"
|
||||
IMRealtimeResyncReasonManual = "manual"
|
||||
)
|
||||
218
internal/pkg/enums/knowledge.go
Normal file
218
internal/pkg/enums/knowledge.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package enums
|
||||
|
||||
type VectorDBType string
|
||||
|
||||
const (
|
||||
VectorDBTypeQdrant VectorDBType = "qdrant"
|
||||
VectorDBTypeLanceDB VectorDBType = "lancedb"
|
||||
)
|
||||
|
||||
var vectorDBTypeLabelMap = map[VectorDBType]string{
|
||||
VectorDBTypeQdrant: "Qdrant",
|
||||
VectorDBTypeLanceDB: "LanceDB",
|
||||
}
|
||||
|
||||
func GetVectorDBTypeLabel(dbType VectorDBType) string {
|
||||
return vectorDBTypeLabelMap[dbType]
|
||||
}
|
||||
|
||||
type KnowledgeDocumentContentType string
|
||||
|
||||
const (
|
||||
KnowledgeDocumentContentTypeHTML KnowledgeDocumentContentType = "html"
|
||||
KnowledgeDocumentContentTypeMarkdown KnowledgeDocumentContentType = "markdown"
|
||||
)
|
||||
|
||||
type KnowledgeBaseType string
|
||||
|
||||
const (
|
||||
KnowledgeBaseTypeDocument KnowledgeBaseType = "document"
|
||||
KnowledgeBaseTypeFAQ KnowledgeBaseType = "faq"
|
||||
)
|
||||
|
||||
var knowledgeBaseTypeLabelMap = map[KnowledgeBaseType]string{
|
||||
KnowledgeBaseTypeDocument: "文档知识库",
|
||||
KnowledgeBaseTypeFAQ: "FAQ知识库",
|
||||
}
|
||||
|
||||
func GetKnowledgeBaseTypeLabel(knowledgeType KnowledgeBaseType) string {
|
||||
return knowledgeBaseTypeLabelMap[knowledgeType]
|
||||
}
|
||||
|
||||
var knowledgeDocumentContentTypeLabelMap = map[KnowledgeDocumentContentType]string{
|
||||
KnowledgeDocumentContentTypeHTML: "HTML",
|
||||
KnowledgeDocumentContentTypeMarkdown: "Markdown",
|
||||
}
|
||||
|
||||
func GetKnowledgeDocumentContentTypeLabel(contentType KnowledgeDocumentContentType) string {
|
||||
return knowledgeDocumentContentTypeLabelMap[contentType]
|
||||
}
|
||||
|
||||
type KnowledgeDocumentIndexStatus string
|
||||
|
||||
const (
|
||||
KnowledgeDocumentIndexStatusPending KnowledgeDocumentIndexStatus = "pending"
|
||||
KnowledgeDocumentIndexStatusIndexed KnowledgeDocumentIndexStatus = "indexed"
|
||||
KnowledgeDocumentIndexStatusFailed KnowledgeDocumentIndexStatus = "failed"
|
||||
)
|
||||
|
||||
var KnowledgeDocumentIndexStatusValues = []KnowledgeDocumentIndexStatus{
|
||||
KnowledgeDocumentIndexStatusPending,
|
||||
KnowledgeDocumentIndexStatusIndexed,
|
||||
KnowledgeDocumentIndexStatusFailed,
|
||||
}
|
||||
|
||||
var knowledgeDocumentIndexStatusLabelMap = map[KnowledgeDocumentIndexStatus]string{
|
||||
KnowledgeDocumentIndexStatusPending: "待索引",
|
||||
KnowledgeDocumentIndexStatusIndexed: "已索引",
|
||||
KnowledgeDocumentIndexStatusFailed: "索引失败",
|
||||
}
|
||||
|
||||
func GetKnowledgeDocumentIndexStatusLabel(status KnowledgeDocumentIndexStatus) string {
|
||||
return knowledgeDocumentIndexStatusLabelMap[status]
|
||||
}
|
||||
|
||||
func IsValidKnowledgeDocumentIndexStatus(status string) bool {
|
||||
for _, item := range KnowledgeDocumentIndexStatusValues {
|
||||
if string(item) == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type KnowledgeChunkType string
|
||||
|
||||
const (
|
||||
KnowledgeChunkTypeText KnowledgeChunkType = "text"
|
||||
KnowledgeChunkTypeFAQ KnowledgeChunkType = "faq"
|
||||
KnowledgeChunkTypeTable KnowledgeChunkType = "table"
|
||||
KnowledgeChunkTypeCode KnowledgeChunkType = "code"
|
||||
)
|
||||
|
||||
var knowledgeChunkTypeLabelMap = map[KnowledgeChunkType]string{
|
||||
KnowledgeChunkTypeText: "文本",
|
||||
KnowledgeChunkTypeFAQ: "问答",
|
||||
KnowledgeChunkTypeTable: "表格",
|
||||
KnowledgeChunkTypeCode: "代码",
|
||||
}
|
||||
|
||||
func GetKnowledgeChunkTypeLabel(chunkType KnowledgeChunkType) string {
|
||||
return knowledgeChunkTypeLabelMap[chunkType]
|
||||
}
|
||||
|
||||
type KnowledgeChunkProvider string
|
||||
|
||||
const (
|
||||
KnowledgeChunkProviderFixed KnowledgeChunkProvider = "fixed"
|
||||
KnowledgeChunkProviderStructured KnowledgeChunkProvider = "structured"
|
||||
KnowledgeChunkProviderFAQ KnowledgeChunkProvider = "faq"
|
||||
KnowledgeChunkProviderSemantic KnowledgeChunkProvider = "semantic"
|
||||
)
|
||||
|
||||
var knowledgeChunkProviderLabelMap = map[KnowledgeChunkProvider]string{
|
||||
KnowledgeChunkProviderFixed: "固定长度",
|
||||
KnowledgeChunkProviderStructured: "结构化分块",
|
||||
KnowledgeChunkProviderFAQ: "问答式分块",
|
||||
KnowledgeChunkProviderSemantic: "语义分块",
|
||||
}
|
||||
|
||||
func GetKnowledgeChunkProviderLabel(provider KnowledgeChunkProvider) string {
|
||||
return knowledgeChunkProviderLabelMap[provider]
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveChannel string
|
||||
|
||||
const (
|
||||
KnowledgeRetrieveChannelIM KnowledgeRetrieveChannel = "im"
|
||||
KnowledgeRetrieveChannelAgentAssist KnowledgeRetrieveChannel = "agent_assist"
|
||||
KnowledgeRetrieveChannelAPI KnowledgeRetrieveChannel = "api"
|
||||
KnowledgeRetrieveChannelDebug KnowledgeRetrieveChannel = "debug"
|
||||
)
|
||||
|
||||
var knowledgeRetrieveChannelLabelMap = map[KnowledgeRetrieveChannel]string{
|
||||
KnowledgeRetrieveChannelIM: "客服会话",
|
||||
KnowledgeRetrieveChannelAgentAssist: "客服助手",
|
||||
KnowledgeRetrieveChannelAPI: "API接口",
|
||||
KnowledgeRetrieveChannelDebug: "调试",
|
||||
}
|
||||
|
||||
func GetKnowledgeRetrieveChannelLabel(channel KnowledgeRetrieveChannel) string {
|
||||
return knowledgeRetrieveChannelLabelMap[channel]
|
||||
}
|
||||
|
||||
type KnowledgeRetrieveScene string
|
||||
|
||||
const (
|
||||
KnowledgeRetrieveSceneFirstResponse KnowledgeRetrieveScene = "first_response"
|
||||
KnowledgeRetrieveSceneAssist KnowledgeRetrieveScene = "assist"
|
||||
KnowledgeRetrieveSceneQA KnowledgeRetrieveScene = "qa"
|
||||
)
|
||||
|
||||
var knowledgeRetrieveSceneLabelMap = map[KnowledgeRetrieveScene]string{
|
||||
KnowledgeRetrieveSceneFirstResponse: "首次回复",
|
||||
KnowledgeRetrieveSceneAssist: "辅助回复",
|
||||
KnowledgeRetrieveSceneQA: "问答",
|
||||
}
|
||||
|
||||
func GetKnowledgeRetrieveSceneLabel(scene KnowledgeRetrieveScene) string {
|
||||
return knowledgeRetrieveSceneLabelMap[scene]
|
||||
}
|
||||
|
||||
type KnowledgeAnswerStatus int
|
||||
|
||||
const (
|
||||
KnowledgeAnswerStatusNormal KnowledgeAnswerStatus = 1
|
||||
KnowledgeAnswerStatusNoAnswer KnowledgeAnswerStatus = 2
|
||||
KnowledgeAnswerStatusFallback KnowledgeAnswerStatus = 3
|
||||
KnowledgeAnswerStatusBlocked KnowledgeAnswerStatus = 4
|
||||
)
|
||||
|
||||
var knowledgeAnswerStatusLabelMap = map[KnowledgeAnswerStatus]string{
|
||||
KnowledgeAnswerStatusNormal: "正常",
|
||||
KnowledgeAnswerStatusNoAnswer: "无答案",
|
||||
KnowledgeAnswerStatusFallback: "兜底回复",
|
||||
KnowledgeAnswerStatusBlocked: "已屏蔽",
|
||||
}
|
||||
|
||||
func GetKnowledgeAnswerStatusLabel(status KnowledgeAnswerStatus) string {
|
||||
return knowledgeAnswerStatusLabelMap[status]
|
||||
}
|
||||
|
||||
type KnowledgeFeedbackType int
|
||||
|
||||
const (
|
||||
KnowledgeFeedbackTypeLike KnowledgeFeedbackType = 1
|
||||
KnowledgeFeedbackTypeDislike KnowledgeFeedbackType = 2
|
||||
KnowledgeFeedbackTypeNotHelpful KnowledgeFeedbackType = 3
|
||||
KnowledgeFeedbackTypeWrongCitation KnowledgeFeedbackType = 4
|
||||
KnowledgeFeedbackTypeOther KnowledgeFeedbackType = 5
|
||||
)
|
||||
|
||||
var knowledgeFeedbackTypeLabelMap = map[KnowledgeFeedbackType]string{
|
||||
KnowledgeFeedbackTypeLike: "点赞",
|
||||
KnowledgeFeedbackTypeDislike: "点踩",
|
||||
KnowledgeFeedbackTypeNotHelpful: "无帮助",
|
||||
KnowledgeFeedbackTypeWrongCitation: "引用错误",
|
||||
KnowledgeFeedbackTypeOther: "其他",
|
||||
}
|
||||
|
||||
func GetKnowledgeFeedbackTypeLabel(feedbackType KnowledgeFeedbackType) string {
|
||||
return knowledgeFeedbackTypeLabelMap[feedbackType]
|
||||
}
|
||||
|
||||
type KnowledgeAnswerMode int
|
||||
|
||||
const (
|
||||
KnowledgeAnswerModeStrict KnowledgeAnswerMode = 1
|
||||
KnowledgeAnswerModeAssist KnowledgeAnswerMode = 2
|
||||
)
|
||||
|
||||
var knowledgeAnswerModeLabelMap = map[KnowledgeAnswerMode]string{
|
||||
KnowledgeAnswerModeStrict: "严格模式",
|
||||
KnowledgeAnswerModeAssist: "辅助模式",
|
||||
}
|
||||
|
||||
func GetKnowledgeAnswerModeLabel(mode KnowledgeAnswerMode) string {
|
||||
return knowledgeAnswerModeLabelMap[mode]
|
||||
}
|
||||
12
internal/pkg/enums/knowledge_test.go
Normal file
12
internal/pkg/enums/knowledge_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package enums
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVectorDBTypeLabelIncludesLanceDB(t *testing.T) {
|
||||
if VectorDBTypeLanceDB != "lancedb" {
|
||||
t.Fatalf("VectorDBTypeLanceDB = %q, want %q", VectorDBTypeLanceDB, "lancedb")
|
||||
}
|
||||
if got := GetVectorDBTypeLabel(VectorDBTypeLanceDB); got != "LanceDB" {
|
||||
t.Fatalf("GetVectorDBTypeLabel(VectorDBTypeLanceDB) = %q, want %q", got, "LanceDB")
|
||||
}
|
||||
}
|
||||
67
internal/pkg/enums/support.go
Normal file
67
internal/pkg/enums/support.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package enums
|
||||
|
||||
type DocPageStatus string
|
||||
|
||||
const (
|
||||
DocPageStatusDraft DocPageStatus = "draft"
|
||||
DocPageStatusPublished DocPageStatus = "published"
|
||||
DocPageStatusHidden DocPageStatus = "hidden"
|
||||
DocPageStatusDeleted DocPageStatus = "deleted"
|
||||
)
|
||||
|
||||
type PostStatus string
|
||||
|
||||
const (
|
||||
PostStatusPending PostStatus = "pending"
|
||||
PostStatusNormal PostStatus = "normal"
|
||||
PostStatusResolved PostStatus = "resolved"
|
||||
PostStatusClosed PostStatus = "closed"
|
||||
PostStatusHidden PostStatus = "hidden"
|
||||
PostStatusDeleted PostStatus = "deleted"
|
||||
)
|
||||
|
||||
type CommentStatus string
|
||||
|
||||
const (
|
||||
CommentStatusNormal CommentStatus = "normal"
|
||||
CommentStatusHidden CommentStatus = "hidden"
|
||||
CommentStatusDeleted CommentStatus = "deleted"
|
||||
)
|
||||
|
||||
type CommentAuthorType string
|
||||
|
||||
const (
|
||||
CommentAuthorTypeUser CommentAuthorType = "user"
|
||||
CommentAuthorTypeEmployee CommentAuthorType = "employee"
|
||||
)
|
||||
|
||||
type ReactionTarget string
|
||||
|
||||
const (
|
||||
ReactionTargetPost ReactionTarget = "post"
|
||||
ReactionTargetComment ReactionTarget = "comment"
|
||||
)
|
||||
|
||||
type ReactionType string
|
||||
|
||||
const (
|
||||
ReactionTypeLike ReactionType = "like"
|
||||
)
|
||||
|
||||
type UserType string
|
||||
|
||||
const (
|
||||
UserTypeUser UserType = "user"
|
||||
UserTypeEmployee UserType = "employee"
|
||||
)
|
||||
|
||||
var UserTypeValues = []UserType{UserTypeUser, UserTypeEmployee}
|
||||
|
||||
var userTypeLabelMap = map[UserType]string{
|
||||
UserTypeUser: "访客",
|
||||
UserTypeEmployee: "员工",
|
||||
}
|
||||
|
||||
func GetUserTypeLabel(userType UserType) string {
|
||||
return userTypeLabelMap[userType]
|
||||
}
|
||||
55
internal/pkg/enums/ticket.go
Normal file
55
internal/pkg/enums/ticket.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package enums
|
||||
|
||||
type TicketStatus string
|
||||
|
||||
const (
|
||||
TicketStatusPending TicketStatus = "pending"
|
||||
TicketStatusInProgress TicketStatus = "in_progress"
|
||||
TicketStatusDone TicketStatus = "done"
|
||||
)
|
||||
|
||||
var TicketStatusValues = []TicketStatus{
|
||||
TicketStatusPending,
|
||||
TicketStatusInProgress,
|
||||
TicketStatusDone,
|
||||
}
|
||||
|
||||
var ticketStatusLabelMap = map[TicketStatus]string{
|
||||
TicketStatusPending: "待处理",
|
||||
TicketStatusInProgress: "处理中",
|
||||
TicketStatusDone: "已处理",
|
||||
}
|
||||
|
||||
func GetTicketStatusLabel(status TicketStatus) string {
|
||||
return ticketStatusLabelMap[status]
|
||||
}
|
||||
|
||||
func IsValidTicketStatus(status string) bool {
|
||||
for _, item := range TicketStatusValues {
|
||||
if string(item) == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type TicketSource string
|
||||
|
||||
const (
|
||||
TicketSourceManual TicketSource = "manual"
|
||||
TicketSourceConversation TicketSource = "conversation"
|
||||
)
|
||||
|
||||
var TicketSourceValues = []TicketSource{
|
||||
TicketSourceManual,
|
||||
TicketSourceConversation,
|
||||
}
|
||||
|
||||
func IsValidTicketSource(source string) bool {
|
||||
for _, item := range TicketSourceValues {
|
||||
if string(item) == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
24
internal/pkg/enums/tool.go
Normal file
24
internal/pkg/enums/tool.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package enums
|
||||
|
||||
type ToolSourceType string
|
||||
|
||||
const (
|
||||
ToolSourceTypeMCP ToolSourceType = "mcp"
|
||||
ToolSourceTypeGraph ToolSourceType = "graph"
|
||||
ToolSourceTypeBuiltin ToolSourceType = "builtin"
|
||||
)
|
||||
|
||||
var ToolSourceTypeValues = []ToolSourceType{
|
||||
ToolSourceTypeMCP,
|
||||
ToolSourceTypeGraph,
|
||||
ToolSourceTypeBuiltin,
|
||||
}
|
||||
|
||||
func IsValidToolSourceType(sourceType ToolSourceType) bool {
|
||||
for _, item := range ToolSourceTypeValues {
|
||||
if item == sourceType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
119
internal/pkg/errorsx/errors.go
Normal file
119
internal/pkg/errorsx/errors.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package errorsx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeInvalidParam = 1000
|
||||
CodeBusinessError = 2000
|
||||
CodeAuthUnauthorized = 3000
|
||||
CodeAuthForbidden = 3001
|
||||
CodeAuthInvalidToken = 3002
|
||||
CodeAuthInvalidAccount = 3003
|
||||
CodeAuthCredentialLocked = 3004
|
||||
)
|
||||
|
||||
func InvalidParam(message string) error {
|
||||
return web.NewError(CodeInvalidParam, message)
|
||||
}
|
||||
|
||||
func InvalidParamI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeInvalidParam, key, args...)
|
||||
}
|
||||
|
||||
func NotFound(message string) error {
|
||||
return web.NewError(CodeInvalidParam, message)
|
||||
}
|
||||
|
||||
func NotFoundI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeInvalidParam, key, args...)
|
||||
}
|
||||
|
||||
func BusinessError(code int, message string) error {
|
||||
return web.NewError(CodeBusinessError+code, message)
|
||||
}
|
||||
|
||||
func BusinessErrorI18n(code int, key string, args ...any) error {
|
||||
return NewI18nError(CodeBusinessError+code, key, args...)
|
||||
}
|
||||
|
||||
func Unauthorized(message string) error {
|
||||
return web.NewError(CodeAuthUnauthorized, message)
|
||||
}
|
||||
|
||||
func UnauthorizedI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeAuthUnauthorized, key, args...)
|
||||
}
|
||||
|
||||
func Forbidden(message string) error {
|
||||
return web.NewError(CodeAuthForbidden, message)
|
||||
}
|
||||
|
||||
func ForbiddenI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeAuthForbidden, key, args...)
|
||||
}
|
||||
|
||||
func InvalidToken(message string) error {
|
||||
return web.NewError(CodeAuthInvalidToken, message)
|
||||
}
|
||||
|
||||
func InvalidTokenI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeAuthInvalidToken, key, args...)
|
||||
}
|
||||
|
||||
func InvalidAccount(message string) error {
|
||||
return web.NewError(CodeAuthInvalidAccount, message)
|
||||
}
|
||||
|
||||
func InvalidAccountI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeAuthInvalidAccount, key, args...)
|
||||
}
|
||||
|
||||
func CredentialLocked(message string) error {
|
||||
return web.NewError(CodeAuthCredentialLocked, message)
|
||||
}
|
||||
|
||||
func CredentialLockedI18n(key string, args ...any) error {
|
||||
return NewI18nError(CodeAuthCredentialLocked, key, args...)
|
||||
}
|
||||
|
||||
type I18nError struct {
|
||||
Code int
|
||||
Key string
|
||||
Args []any
|
||||
}
|
||||
|
||||
func NewI18nError(code int, key string, args ...any) *I18nError {
|
||||
return &I18nError{Code: code, Key: key, Args: args}
|
||||
}
|
||||
|
||||
func (e *I18nError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Message(i18nx.DefaultLocale)
|
||||
}
|
||||
|
||||
func (e *I18nError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return web.NewError(e.Code, e.Error())
|
||||
}
|
||||
|
||||
func (e *I18nError) Message(locale string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return i18nx.Getf(locale, e.Key, e.Args...)
|
||||
}
|
||||
|
||||
func (e *I18nError) JsonResult(locale string) *web.JsonResult {
|
||||
if e == nil {
|
||||
return web.JsonSuccess()
|
||||
}
|
||||
return web.JsonErrorCode(e.Code, e.Message(locale))
|
||||
}
|
||||
191
internal/pkg/eventbus/bus.go
Normal file
191
internal/pkg/eventbus/bus.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Handler[T any] func(ctx context.Context, event T) error
|
||||
|
||||
type ErrorHandler func(ctx context.Context, err error)
|
||||
|
||||
type handlerEntry[T any] struct {
|
||||
id uint64
|
||||
handler Handler[T]
|
||||
}
|
||||
|
||||
type Bus[T any] struct {
|
||||
mu sync.RWMutex
|
||||
handlers []handlerEntry[T]
|
||||
nextID uint64
|
||||
|
||||
onError ErrorHandler
|
||||
|
||||
// 用于限制 PublishAsync 的 goroutine 并发数;nil 表示不限制
|
||||
asyncSem chan struct{}
|
||||
}
|
||||
|
||||
func New[T any](opts ...Option[T]) *Bus[T] {
|
||||
b := &Bus[T]{}
|
||||
for _, opt := range opts {
|
||||
opt(b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type Option[T any] func(*Bus[T])
|
||||
|
||||
func WithErrorHandler[T any](fn ErrorHandler) Option[T] {
|
||||
return func(b *Bus[T]) {
|
||||
b.onError = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithAsyncConcurrency[T any](n int) Option[T] {
|
||||
return func(b *Bus[T]) {
|
||||
if n > 0 {
|
||||
b.asyncSem = make(chan struct{}, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe 返回 handlerID 和取消订阅函数
|
||||
func (b *Bus[T]) Subscribe(h Handler[T]) (uint64, func()) {
|
||||
if h == nil {
|
||||
panic("eventbus: nil handler")
|
||||
}
|
||||
|
||||
id := atomic.AddUint64(&b.nextID, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
b.handlers = append(b.handlers, handlerEntry[T]{
|
||||
id: id,
|
||||
handler: h,
|
||||
})
|
||||
b.mu.Unlock()
|
||||
|
||||
return id, func() {
|
||||
b.Unsubscribe(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus[T]) SubscribeOnce(h Handler[T]) (uint64, func()) {
|
||||
if h == nil {
|
||||
panic("eventbus: nil handler")
|
||||
}
|
||||
|
||||
var id uint64
|
||||
var called atomic.Bool
|
||||
|
||||
wrapper := func(ctx context.Context, event T) error {
|
||||
if !called.CompareAndSwap(false, true) {
|
||||
return nil
|
||||
}
|
||||
b.Unsubscribe(id)
|
||||
return h(ctx, event)
|
||||
}
|
||||
|
||||
id = atomic.AddUint64(&b.nextID, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
b.handlers = append(b.handlers, handlerEntry[T]{
|
||||
id: id,
|
||||
handler: wrapper,
|
||||
})
|
||||
b.mu.Unlock()
|
||||
|
||||
return id, func() {
|
||||
b.Unsubscribe(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus[T]) Unsubscribe(id uint64) {
|
||||
b.mu.Lock()
|
||||
for i, entry := range b.handlers {
|
||||
if entry.id != id {
|
||||
continue
|
||||
}
|
||||
b.handlers = append(b.handlers[:i], b.handlers[i+1:]...)
|
||||
break
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bus[T]) Publish(ctx context.Context, event T) error {
|
||||
handlers := b.snapshotHandlers()
|
||||
errs := make([]error, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if err := b.callHandler(ctx, h, event); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (b *Bus[T]) PublishAsync(ctx context.Context, event T) {
|
||||
handlers := b.snapshotHandlers()
|
||||
for _, h := range handlers {
|
||||
go b.callHandlerAsync(ctx, h, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus[T]) HandlerCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.handlers)
|
||||
}
|
||||
|
||||
func (b *Bus[T]) snapshotHandlers() []Handler[T] {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
handlers := make([]Handler[T], 0, len(b.handlers))
|
||||
for _, entry := range b.handlers {
|
||||
handlers = append(handlers, entry.handler)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (b *Bus[T]) callHandlerAsync(ctx context.Context, h Handler[T], event T) {
|
||||
if b.asyncSem != nil {
|
||||
select {
|
||||
case b.asyncSem <- struct{}{}:
|
||||
defer func() { <-b.asyncSem }()
|
||||
case <-ctx.Done():
|
||||
b.handleError(ctx, ctx.Err())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.callHandler(ctx, h, event); err != nil {
|
||||
b.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus[T]) callHandler(ctx context.Context, h Handler[T], event T) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("event handler panic: %v\n%s", r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
if err = h(ctx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bus[T]) handleError(ctx context.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if b.onError != nil {
|
||||
b.onError(ctx, err)
|
||||
return
|
||||
}
|
||||
slog.Error("eventbus handler failed", "error", err)
|
||||
}
|
||||
199
internal/pkg/eventbus/bus_test.go
Normal file
199
internal/pkg/eventbus/bus_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testEvent struct {
|
||||
ID int64
|
||||
}
|
||||
|
||||
func TestPackageSubscribeAndPublish(t *testing.T) {
|
||||
resetManagerForTest(t)
|
||||
|
||||
var got atomic.Int64
|
||||
_, unsubscribe := Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
got.Store(event.ID)
|
||||
return nil
|
||||
})
|
||||
defer unsubscribe()
|
||||
|
||||
if err := Publish(context.Background(), testEvent{ID: 42}); err != nil {
|
||||
t.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Load() != 42 {
|
||||
t.Fatalf("expected event ID 42, got %d", got.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishReturnsJoinedHandlerErrors(t *testing.T) {
|
||||
firstErr := errors.New("first")
|
||||
secondErr := errors.New("second")
|
||||
bus := New[testEvent]()
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
return firstErr
|
||||
})
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
return secondErr
|
||||
})
|
||||
|
||||
err := bus.Publish(context.Background(), testEvent{})
|
||||
if !errors.Is(err, firstErr) {
|
||||
t.Fatalf("expected joined error to include first error, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, secondErr) {
|
||||
t.Fatalf("expected joined error to include second error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCallsHandlersInSubscribeOrder(t *testing.T) {
|
||||
bus := New[testEvent]()
|
||||
calls := make([]int, 0, 3)
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 1)
|
||||
return nil
|
||||
})
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 2)
|
||||
return nil
|
||||
})
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 3)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := bus.Publish(context.Background(), testEvent{}); err != nil {
|
||||
t.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(calls, []int{1, 2, 3}) {
|
||||
t.Fatalf("expected handlers to run in subscribe order, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeKeepsRemainingHandlerOrder(t *testing.T) {
|
||||
bus := New[testEvent]()
|
||||
calls := make([]int, 0, 2)
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 1)
|
||||
return nil
|
||||
})
|
||||
_, unsubscribe := bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 2)
|
||||
return nil
|
||||
})
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
calls = append(calls, 3)
|
||||
return nil
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
|
||||
if err := bus.Publish(context.Background(), testEvent{}); err != nil {
|
||||
t.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(calls, []int{1, 3}) {
|
||||
t.Fatalf("expected remaining handlers to keep order, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicReturnsError(t *testing.T) {
|
||||
bus := New[testEvent]()
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
panic("boom")
|
||||
})
|
||||
|
||||
err := bus.Publish(context.Background(), testEvent{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected panic to be returned as error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishAsyncCallsErrorHandler(t *testing.T) {
|
||||
handlerErr := errors.New("async failed")
|
||||
var handled atomic.Int64
|
||||
done := make(chan struct{})
|
||||
bus := New[testEvent](WithErrorHandler[testEvent](func(ctx context.Context, err error) {
|
||||
if errors.Is(err, handlerErr) {
|
||||
handled.Add(1)
|
||||
}
|
||||
close(done)
|
||||
}))
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
return handlerErr
|
||||
})
|
||||
|
||||
bus.PublishAsync(context.Background(), testEvent{})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("expected async error handler to be called")
|
||||
}
|
||||
|
||||
if handled.Load() != 1 {
|
||||
t.Fatalf("expected error handler to be called once, got %d", handled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeOnceConcurrentPublishOnlyCallsOnce(t *testing.T) {
|
||||
bus := New[testEvent]()
|
||||
var calls atomic.Int64
|
||||
|
||||
bus.SubscribeOnce(func(ctx context.Context, event testEvent) error {
|
||||
calls.Add(1)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
const workers = 32
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = bus.Publish(context.Background(), testEvent{})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("expected once handler to be called once, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishAsyncAllowsReentrantPublishWithConcurrencyLimit(t *testing.T) {
|
||||
bus := New[testEvent](WithAsyncConcurrency[testEvent](1))
|
||||
done := make(chan struct{})
|
||||
var calls atomic.Int64
|
||||
|
||||
bus.Subscribe(func(ctx context.Context, event testEvent) error {
|
||||
if calls.Add(1) == 1 {
|
||||
bus.PublishAsync(ctx, testEvent{ID: 2})
|
||||
return nil
|
||||
}
|
||||
close(done)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.PublishAsync(context.Background(), testEvent{ID: 1})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("expected reentrant async publish to complete")
|
||||
}
|
||||
}
|
||||
80
internal/pkg/eventbus/manager.go
Normal file
80
internal/pkg/eventbus/manager.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
buss = make(map[reflect.Type]any)
|
||||
)
|
||||
|
||||
// Register initializes the global bus for T. If the bus already exists, the
|
||||
// existing instance is returned and later options are ignored.
|
||||
func Register[T any](opts ...Option[T]) *Bus[T] {
|
||||
key := eventTypeOf[T]()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if bus, ok := buss[key]; ok {
|
||||
return bus.(*Bus[T])
|
||||
}
|
||||
|
||||
created := New(opts...)
|
||||
buss[key] = created
|
||||
return created
|
||||
}
|
||||
|
||||
// Get returns the global bus for T, creating it with default options if needed.
|
||||
func Get[T any]() *Bus[T] {
|
||||
key := eventTypeOf[T]()
|
||||
|
||||
mu.RLock()
|
||||
bus, ok := buss[key]
|
||||
mu.RUnlock()
|
||||
if ok {
|
||||
return bus.(*Bus[T])
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if bus, ok = buss[key]; ok {
|
||||
return bus.(*Bus[T])
|
||||
}
|
||||
|
||||
created := New[T]()
|
||||
buss[key] = created
|
||||
return created
|
||||
}
|
||||
|
||||
func eventTypeOf[T any]() reflect.Type {
|
||||
typ := reflect.TypeFor[T]()
|
||||
if typ == nil {
|
||||
panic("eventbus: nil event type")
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
// Subscribe registers a handler on the global bus for T.
|
||||
func Subscribe[T any](h Handler[T]) (uint64, func()) {
|
||||
return Get[T]().Subscribe(h)
|
||||
}
|
||||
|
||||
// SubscribeOnce registers a handler that runs at most once on the global bus for T.
|
||||
func SubscribeOnce[T any](h Handler[T]) (uint64, func()) {
|
||||
return Get[T]().SubscribeOnce(h)
|
||||
}
|
||||
|
||||
// Publish synchronously publishes event to the global bus for T.
|
||||
func Publish[T any](ctx context.Context, event T) error {
|
||||
return Get[T]().Publish(ctx, event)
|
||||
}
|
||||
|
||||
// PublishAsync asynchronously publishes event to the global bus for T.
|
||||
func PublishAsync[T any](ctx context.Context, event T) {
|
||||
Get[T]().PublishAsync(ctx, event)
|
||||
}
|
||||
102
internal/pkg/eventbus/manager_test.go
Normal file
102
internal/pkg/eventbus/manager_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type userCreatedEvent struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type orderCreatedEvent struct {
|
||||
OrderID int64
|
||||
}
|
||||
|
||||
func TestGetReturnsSameBusForSameType(t *testing.T) {
|
||||
resetManagerForTest(t)
|
||||
|
||||
first := Get[userCreatedEvent]()
|
||||
second := Get[userCreatedEvent]()
|
||||
|
||||
if first != second {
|
||||
t.Fatalf("expected same bus instance for same event type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReturnsDifferentBusForDifferentTypes(t *testing.T) {
|
||||
resetManagerForTest(t)
|
||||
|
||||
userBus := Get[userCreatedEvent]()
|
||||
orderBus := Get[orderCreatedEvent]()
|
||||
|
||||
if userBus == any(orderBus) {
|
||||
t.Fatalf("expected different bus instances for different event types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCreatesOnlyOneBusUnderConcurrency(t *testing.T) {
|
||||
resetManagerForTest(t)
|
||||
|
||||
const workers = 32
|
||||
|
||||
results := make(chan *Bus[userCreatedEvent], workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- Get[userCreatedEvent]()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var first *Bus[userCreatedEvent]
|
||||
for bus := range results {
|
||||
if first == nil {
|
||||
first = bus
|
||||
continue
|
||||
}
|
||||
if bus != first {
|
||||
t.Fatalf("expected same bus instance for concurrent access")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterReturnsConfiguredGlobalBus(t *testing.T) {
|
||||
resetManagerForTest(t)
|
||||
|
||||
var handled bool
|
||||
registered := Register[testEvent](WithErrorHandler[testEvent](func(ctx context.Context, err error) {
|
||||
handled = true
|
||||
}))
|
||||
got := Get[testEvent]()
|
||||
|
||||
if registered != got {
|
||||
t.Fatalf("expected registered bus to be returned by Get")
|
||||
}
|
||||
|
||||
got.handleError(context.Background(), assertErr{})
|
||||
if !handled {
|
||||
t.Fatalf("expected registered error handler to be used")
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr struct{}
|
||||
|
||||
func (assertErr) Error() string {
|
||||
return "assert"
|
||||
}
|
||||
|
||||
func resetManagerForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
mu.Lock()
|
||||
buss = make(map[reflect.Type]any)
|
||||
mu.Unlock()
|
||||
}
|
||||
188
internal/pkg/ginx/static.go
Normal file
188
internal/pkg/ginx/static.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type noDirFS struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
type DirOptions struct {
|
||||
ShowList bool
|
||||
SPA bool
|
||||
IndexName string
|
||||
}
|
||||
|
||||
type SPAOptions struct {
|
||||
Root string
|
||||
EmbeddedFS fs.FS
|
||||
EmbeddedRoot string
|
||||
DirOptions DirOptions
|
||||
NotFoundPrefixes []string
|
||||
NotFoundHandler gin.HandlerFunc
|
||||
// RootRedirect, when set, answers "/" with a redirect instead of serving
|
||||
// the SPA. The static export has no index.html at the root, so without this
|
||||
// the site origin answers 404.
|
||||
RootRedirect string
|
||||
}
|
||||
|
||||
func StaticFiles(root string) http.FileSystem {
|
||||
return noDirFS{fs: http.Dir(root)}
|
||||
}
|
||||
|
||||
func (n noDirFS) Open(name string) (http.File, error) {
|
||||
file, err := n.fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func HandleSPA(engine *gin.Engine, options SPAOptions) gin.HandlerFunc {
|
||||
handler := NewSPAHandler(options.Root, options.EmbeddedFS, options.EmbeddedRoot, options.DirOptions)
|
||||
rootHandler := handler
|
||||
if options.RootRedirect != "" {
|
||||
target := options.RootRedirect
|
||||
rootHandler = func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusFound, target)
|
||||
}
|
||||
}
|
||||
engine.GET("/", rootHandler)
|
||||
engine.HEAD("/", rootHandler)
|
||||
engine.NoRoute(func(ctx *gin.Context) {
|
||||
for _, prefix := range options.NotFoundPrefixes {
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, prefix) {
|
||||
if options.NotFoundHandler != nil {
|
||||
options.NotFoundHandler(ctx)
|
||||
return
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
handler(ctx)
|
||||
})
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewSPAHandler(root string, embeddedFS fs.FS, embeddedRoot string, options DirOptions) gin.HandlerFunc {
|
||||
if _, err := os.Stat(path.Join(root, options.IndexName)); err == nil {
|
||||
return DirHandler(http.Dir(root), options)
|
||||
}
|
||||
|
||||
spaFS, err := fs.Sub(embeddedFS, embeddedRoot)
|
||||
if err != nil {
|
||||
slog.Error("failed to load embedded SPA files", slog.Any("err", err))
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return DirHandler(http.FS(spaFS), options)
|
||||
}
|
||||
|
||||
func tryOpenFile(fileSystem http.FileSystem, filePath string) (http.File, os.FileInfo, bool) {
|
||||
file, err := fileSystem.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
_ = file.Close()
|
||||
return nil, nil, false
|
||||
}
|
||||
return file, info, true
|
||||
}
|
||||
|
||||
func DirHandler(fileSystem http.FileSystem, options DirOptions) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
name := path.Clean(ctx.Request.URL.Path)
|
||||
if name == "." || name == "/" {
|
||||
name = "/" + options.IndexName
|
||||
}
|
||||
|
||||
file, err := fileSystem.Open(name)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
if options.IndexName != "" {
|
||||
indexName := path.Join(name, options.IndexName)
|
||||
if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok {
|
||||
defer indexFile.Close()
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Support Next.js static export where directory name matches page html (e.g. /dashboard -> /dashboard.html)
|
||||
htmlName := strings.TrimRight(name, "/") + ".html"
|
||||
if htmlFile, htmlInfo, ok := tryOpenFile(fileSystem, htmlName); ok {
|
||||
defer htmlFile.Close()
|
||||
serveFile(ctx, htmlName, htmlFile, htmlInfo)
|
||||
return
|
||||
}
|
||||
if !options.ShowList {
|
||||
if options.SPA && options.IndexName != "" {
|
||||
indexName := "/" + options.IndexName
|
||||
if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok {
|
||||
defer indexFile.Close()
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
serveFile(ctx, name, file, info)
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Try name.html (for sub-routes like /dashboard/login -> /dashboard/login.html)
|
||||
htmlName := strings.TrimRight(name, "/") + ".html"
|
||||
if htmlFile, htmlInfo, ok := tryOpenFile(fileSystem, htmlName); ok {
|
||||
defer htmlFile.Close()
|
||||
serveFile(ctx, htmlName, htmlFile, htmlInfo)
|
||||
return
|
||||
}
|
||||
|
||||
if options.SPA && options.IndexName != "" {
|
||||
indexName := "/" + options.IndexName
|
||||
if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok {
|
||||
defer indexFile.Close()
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func serveFile(ctx *gin.Context, name string, file http.File, info os.FileInfo) {
|
||||
http.ServeContent(ctx.Writer, ctx.Request, name, info.ModTime(), file)
|
||||
}
|
||||
218
internal/pkg/ginx/static_test.go
Normal file
218
internal/pkg/ginx/static_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDirHandlerWithSPA(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "manifest"), []byte("manifest content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/assets/not-exists.css", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/images/not-exists.png", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/fonts/not-exists", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/manifest", wantStatus: http.StatusOK, wantBody: "manifest content"},
|
||||
{path: "/support", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
}
|
||||
handler := DirHandler(http.Dir(root), DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
|
||||
handler(ctx)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d; body=%q", tt.path, rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesDoesNotOpenDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileSystem := StaticFiles(root)
|
||||
if file, err := fileSystem.Open("/file.txt"); err != nil {
|
||||
t.Fatalf("Open(file.txt) err=%v", err)
|
||||
} else {
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
if file, err := fileSystem.Open("/"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("Open(/) succeeded, want error")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("Open(/) err=%v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSPAKeepsNotFoundPrefixes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
HandleSPA(engine, SPAOptions{
|
||||
Root: root,
|
||||
DirOptions: DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: []string{"/api/"},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/support", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSPARootRedirect(t *testing.T) {
|
||||
// Mirrors the real export: no index.html at the root, only the exported
|
||||
// route shells.
|
||||
engine := gin.New()
|
||||
HandleSPA(engine, SPAOptions{
|
||||
EmbeddedFS: fstest.MapFS{
|
||||
"dashboard.html": {Data: []byte("<html>dashboard</html>")},
|
||||
"support.html": {Data: []byte("<html>support</html>")},
|
||||
},
|
||||
EmbeddedRoot: ".",
|
||||
RootRedirect: "/support",
|
||||
DirOptions: DirOptions{ShowList: false, SPA: true, IndexName: "index.html"},
|
||||
NotFoundPrefixes: []string{"/api/"},
|
||||
})
|
||||
|
||||
t.Run("root redirects", func(t *testing.T) {
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(method, "/", nil))
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("%s / status=%d want %d", method, rec.Code, http.StatusFound)
|
||||
}
|
||||
if location := rec.Header().Get("Location"); location != "/support" {
|
||||
t.Fatalf("%s / Location=%q want %q", method, location, "/support")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other routes still serve the SPA", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/support", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("/support status=%d want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "<html>support</html>" {
|
||||
t.Fatalf("/support body=%q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api 404 is unaffected", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/not-exists", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("/api/not-exists status=%d want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDirHandlerWithNextExportPages(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>home</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "dashboard.html"), []byte("<html>dashboard</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dashboardDir := filepath.Join(root, "dashboard")
|
||||
if err := os.MkdirAll(dashboardDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dashboardDir, "login.html"), []byte("<html>login</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := DirHandler(http.Dir(root), DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantBody: "<html>home</html>"},
|
||||
{path: "/dashboard", wantStatus: http.StatusOK, wantBody: "<html>dashboard</html>"},
|
||||
{path: "/dashboard/", wantStatus: http.StatusOK, wantBody: "<html>dashboard</html>"},
|
||||
{path: "/dashboard/login", wantStatus: http.StatusOK, wantBody: "<html>login</html>"},
|
||||
{path: "/dashboard/login/", wantStatus: http.StatusOK, wantBody: "<html>login</html>"},
|
||||
{path: "/other-spa-route", wantStatus: http.StatusOK, wantBody: "<html>home</html>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
|
||||
handler(ctx)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
46
internal/pkg/httpx/context.go
Normal file
46
internal/pkg/httpx/context.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/pkg/openidentity"
|
||||
"agent-desk/internal/pkg/tracex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxKeyExternalUser = "externalUser"
|
||||
)
|
||||
|
||||
func SetExternalUser(ctx *gin.Context, ext *openidentity.ExternalUser) {
|
||||
ctx.Set(ctxKeyExternalUser, ext)
|
||||
}
|
||||
|
||||
func GetExternalUser(ctx *gin.Context) *openidentity.ExternalUser {
|
||||
v, _ := ctx.Get(ctxKeyExternalUser)
|
||||
ext, _ := v.(*openidentity.ExternalUser)
|
||||
return ext
|
||||
}
|
||||
|
||||
func GetChannelID(ctx *gin.Context) string {
|
||||
if channelID := ctx.GetHeader("X-Channel-ID"); strs.IsNotBlank(channelID) {
|
||||
return channelID
|
||||
}
|
||||
if channelID, _ := params.Get(ctx, "channelId"); strs.IsNotBlank(channelID) {
|
||||
return channelID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetRequestID(ctx *gin.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if value, ok := ctx.Get(tracex.GinRequestIDKey); ok {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return tracex.NormalizeRequestID(requestID)
|
||||
}
|
||||
}
|
||||
return tracex.NormalizeRequestID(ctx.GetHeader(tracex.RequestIDHeader))
|
||||
}
|
||||
12
internal/pkg/httpx/error.go
Normal file
12
internal/pkg/httpx/error.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func JsonErrorMsg(ctx *gin.Context, key string, args ...any) *web.JsonResult {
|
||||
return web.JsonErrorMsg(i18nx.T(ctx, key, args...))
|
||||
}
|
||||
289
internal/pkg/httpx/params/params.go
Normal file
289
internal/pkg/httpx/params/params.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/mlogclub/simple/common/dates"
|
||||
"github.com/mlogclub/simple/common/jsons"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var (
|
||||
decoder = schema.NewDecoder()
|
||||
validate = validator.New()
|
||||
)
|
||||
|
||||
func init() {
|
||||
decoder.SetAliasTag("form")
|
||||
decoder.ZeroEmpty(true)
|
||||
decoder.IgnoreUnknownKeys(true)
|
||||
}
|
||||
|
||||
func paramError(name string) error {
|
||||
return fmt.Errorf("unable to find param value '%s'", name)
|
||||
}
|
||||
|
||||
func ReadForm(ctx *gin.Context, obj any) error {
|
||||
if ctx == nil {
|
||||
return errors.New("request context is nil")
|
||||
}
|
||||
if err := ctx.Request.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
values := ctx.Request.Form
|
||||
if len(values) == 0 {
|
||||
if err := ctx.Request.ParseMultipartForm(32 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||
return err
|
||||
}
|
||||
values = ctx.Request.Form
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := decoder.Decode(obj, values); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStruct(obj)
|
||||
}
|
||||
|
||||
func ReadJSON(ctx *gin.Context, obj any) error {
|
||||
if ctx == nil {
|
||||
return errors.New("request context is nil")
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(obj); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateStruct(obj)
|
||||
}
|
||||
|
||||
func validateStruct(obj any) error {
|
||||
if obj == nil {
|
||||
return nil
|
||||
}
|
||||
value := reflect.ValueOf(obj)
|
||||
for value.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
return validate.Struct(obj)
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
if value.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
return validate.Struct(obj)
|
||||
}
|
||||
|
||||
func Get(ctx *gin.Context, name string) (string, bool) {
|
||||
str := FormValue(ctx, name)
|
||||
return str, str != ""
|
||||
}
|
||||
|
||||
func GetInt64(ctx *gin.Context, name string) (int64, bool) {
|
||||
str, ok := Get(ctx, name)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
value, err := cast.ToInt64E(str)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func GetInt(ctx *gin.Context, name string) (int, bool) {
|
||||
str, ok := Get(ctx, name)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
value, err := cast.ToIntE(str)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func GetBool(ctx *gin.Context, name string) (bool, bool) {
|
||||
str, ok := Get(ctx, name)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
value, err := cast.ToBoolE(str)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func GetTime(ctx *gin.Context, name string) *time.Time {
|
||||
value, _ := Get(ctx, name)
|
||||
if strs.IsBlank(value) {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{dates.FmtDateTime, dates.FmtDate, dates.FmtDateTimeNoSeconds}
|
||||
for _, layout := range layouts {
|
||||
if ret, err := dates.Parse(value, layout); err == nil {
|
||||
return &ret
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetInt64Arr(ctx *gin.Context, name string) []int64 {
|
||||
str, ok := Get(ctx, name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if strings.HasPrefix(str, "[") && strings.HasSuffix(str, "]") {
|
||||
var ret []int64
|
||||
if err := jsons.Parse(str, &ret); err != nil {
|
||||
slog.Error(err.Error())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
return StrSplitToInt64Arr(str)
|
||||
}
|
||||
|
||||
func StrSplitToInt64Arr(str string) (ret []int64) {
|
||||
if strs.IsBlank(str) {
|
||||
return ret
|
||||
}
|
||||
for _, s := range strings.Split(str, ",") {
|
||||
i, err := cast.ToInt64E(strings.TrimSpace(s))
|
||||
if err == nil {
|
||||
ret = append(ret, i)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FormValue(ctx *gin.Context, name string) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if value := ctx.PostForm(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return ctx.Query(name)
|
||||
}
|
||||
|
||||
func FormValueRequired(ctx *gin.Context, name string) (string, error) {
|
||||
str := FormValue(ctx, name)
|
||||
if len(str) == 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.param.required", name)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func FormValueDefault(ctx *gin.Context, name, def string) string {
|
||||
if value := FormValue(ctx, name); value != "" {
|
||||
return value
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func FormValueInt(ctx *gin.Context, name string) (int, error) {
|
||||
str := FormValue(ctx, name)
|
||||
if str == "" {
|
||||
return 0, paramError(name)
|
||||
}
|
||||
return strconv.Atoi(str)
|
||||
}
|
||||
|
||||
func FormValueIntDefault(ctx *gin.Context, name string, def int) int {
|
||||
if v, err := FormValueInt(ctx, name); err == nil {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func FormValueInt64(ctx *gin.Context, name string) (int64, error) {
|
||||
str := FormValue(ctx, name)
|
||||
if str == "" {
|
||||
return 0, paramError(name)
|
||||
}
|
||||
return strconv.ParseInt(str, 10, 64)
|
||||
}
|
||||
|
||||
func FormValueInt64Default(ctx *gin.Context, name string, def int64) int64 {
|
||||
if v, err := FormValueInt64(ctx, name); err == nil {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func FormValueInt64Array(ctx *gin.Context, name string) []int64 {
|
||||
str := strings.TrimSpace(FormValue(ctx, name))
|
||||
if strings.HasPrefix(str, "[") && strings.HasSuffix(str, "]") {
|
||||
var ret []int64
|
||||
if err := jsons.Parse(str, &ret); err != nil {
|
||||
slog.Error(err.Error())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
return StrSplitToInt64Arr(str)
|
||||
}
|
||||
|
||||
func FormValueStringArray(ctx *gin.Context, name string) []string {
|
||||
str := FormValue(ctx, name)
|
||||
if len(str) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ret []string
|
||||
for _, s := range strings.Split(str, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 0 {
|
||||
ret = append(ret, s)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FormValueBool(ctx *gin.Context, name string) (bool, error) {
|
||||
str := FormValue(ctx, name)
|
||||
if str == "" {
|
||||
return false, paramError(name)
|
||||
}
|
||||
return strconv.ParseBool(str)
|
||||
}
|
||||
|
||||
func FormValueBoolDefault(ctx *gin.Context, name string, def bool) bool {
|
||||
str := FormValue(ctx, name)
|
||||
if str == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseBool(str)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func FormDate(ctx *gin.Context, name string) *time.Time {
|
||||
return GetTime(ctx, name)
|
||||
}
|
||||
|
||||
func GetPaging(ctx *gin.Context) *sqls.Paging {
|
||||
page := FormValueIntDefault(ctx, "page", 1)
|
||||
limit := FormValueIntDefault(ctx, "limit", 20)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
return &sqls.Paging{Page: page, Limit: limit}
|
||||
}
|
||||
31
internal/pkg/httpx/params/params_test.go
Normal file
31
internal/pkg/httpx/params/params_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newJSONContext(body string) *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestReadJSONAcceptsRootArray(t *testing.T) {
|
||||
var ids []int64
|
||||
|
||||
if err := ReadJSON(newJSONContext(`[3,4]`), &ids); err != nil {
|
||||
t.Fatalf("ReadJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(ids) != 2 || ids[0] != 3 || ids[1] != 4 {
|
||||
t.Fatalf("expected ids [3 4], got %#v", ids)
|
||||
}
|
||||
}
|
||||
79
internal/pkg/httpx/params/query_filters.go
Normal file
79
internal/pkg/httpx/params/query_filters.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/common/strs/strcase"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type QueryOp string
|
||||
|
||||
const (
|
||||
Eq QueryOp = "eq"
|
||||
Gt QueryOp = "gt"
|
||||
Lt QueryOp = "lt"
|
||||
Gte QueryOp = "gte"
|
||||
Lte QueryOp = "lte"
|
||||
Like QueryOp = "like"
|
||||
In QueryOp = "in"
|
||||
Starting QueryOp = "starting"
|
||||
Ending QueryOp = "ending"
|
||||
)
|
||||
|
||||
type QueryFilter struct {
|
||||
ParamName string
|
||||
Op QueryOp
|
||||
ColumnName string
|
||||
ValueWrapper func(origin string) string
|
||||
}
|
||||
|
||||
func NewPagedSqlCnd(ctx *gin.Context, filters ...QueryFilter) *sqls.Cnd {
|
||||
cnd := NewSqlCnd(ctx, filters...)
|
||||
p := GetPaging(ctx)
|
||||
cnd.Page(p.Page, p.Limit)
|
||||
return cnd
|
||||
}
|
||||
|
||||
func NewSqlCnd(ctx *gin.Context, filters ...QueryFilter) *sqls.Cnd {
|
||||
cnd := sqls.NewCnd()
|
||||
for _, filter := range filters {
|
||||
columnName := filter.ColumnName
|
||||
paramValue := FormValue(ctx, filter.ParamName)
|
||||
if strs.IsBlank(string(filter.Op)) {
|
||||
filter.Op = Eq
|
||||
}
|
||||
if filter.ValueWrapper != nil {
|
||||
paramValue = filter.ValueWrapper(paramValue)
|
||||
}
|
||||
if strs.IsBlank(paramValue) {
|
||||
continue
|
||||
}
|
||||
if strs.IsBlank(columnName) {
|
||||
columnName = strcase.ToSnake(filter.ParamName)
|
||||
}
|
||||
switch filter.Op {
|
||||
case Eq:
|
||||
cnd.Eq(columnName, paramValue)
|
||||
case Gt:
|
||||
cnd.Gt(columnName, paramValue)
|
||||
case Lt:
|
||||
cnd.Lt(columnName, paramValue)
|
||||
case Gte:
|
||||
cnd.Gte(columnName, paramValue)
|
||||
case Lte:
|
||||
cnd.Lte(columnName, paramValue)
|
||||
case Like:
|
||||
cnd.Like(columnName, paramValue)
|
||||
case Starting:
|
||||
cnd.Starting(columnName, paramValue)
|
||||
case Ending:
|
||||
cnd.Ending(columnName, paramValue)
|
||||
case In:
|
||||
cnd.In(columnName, strings.Split(paramValue, ","))
|
||||
}
|
||||
}
|
||||
return cnd
|
||||
}
|
||||
106
internal/pkg/httpx/params/query_params.go
Normal file
106
internal/pkg/httpx/params/query_params.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/common/strs/strcase"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type QueryParams struct {
|
||||
Ctx *gin.Context
|
||||
sqls.Cnd
|
||||
}
|
||||
|
||||
func NewQueryParams(ctx *gin.Context) *QueryParams {
|
||||
return &QueryParams{Ctx: ctx}
|
||||
}
|
||||
|
||||
func (q *QueryParams) getValueByColumn(column string) string {
|
||||
if q.Ctx == nil {
|
||||
return ""
|
||||
}
|
||||
return FormValue(q.Ctx, strcase.ToLowerCamel(column))
|
||||
}
|
||||
|
||||
func (q *QueryParams) EqByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Eq(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) NotEqByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.NotEq(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) GtByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Gt(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) GteByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Gte(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) LtByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Lt(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) LteByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Lte(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) LikeByReq(column string) *QueryParams {
|
||||
if value := q.getValueByColumn(column); len(value) > 0 {
|
||||
q.Like(column, value)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) PageByReq() *QueryParams {
|
||||
if q.Ctx == nil {
|
||||
return q
|
||||
}
|
||||
paging := GetPaging(q.Ctx)
|
||||
q.Page(paging.Page, paging.Limit)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) Asc(column string) *QueryParams {
|
||||
q.Orders = append(q.Orders, sqls.OrderByCol{Column: column, Asc: true})
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) Desc(column string) *QueryParams {
|
||||
q.Orders = append(q.Orders, sqls.OrderByCol{Column: column, Asc: false})
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) Limit(limit int) *QueryParams {
|
||||
q.Page(1, limit)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QueryParams) Page(page, limit int) *QueryParams {
|
||||
if q.Paging == nil {
|
||||
q.Paging = &sqls.Paging{Page: page, Limit: limit}
|
||||
} else {
|
||||
q.Paging.Page = page
|
||||
q.Paging.Limit = limit
|
||||
}
|
||||
return q
|
||||
}
|
||||
19
internal/pkg/httpx/path.go
Normal file
19
internal/pkg/httpx/path.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func GetPathInt64(ctx *gin.Context, name string) (int64, bool) {
|
||||
value, err := strconv.ParseInt(ctx.Param(name), 10, 64)
|
||||
if err != nil {
|
||||
WriteHttpStatusJSON(ctx, http.StatusBadRequest, web.JsonErrorMsg(i18nx.T(ctx, "error.path.invalid")))
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
75
internal/pkg/httpx/response.go
Normal file
75
internal/pkg/httpx/response.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
type cursorData struct {
|
||||
results any
|
||||
cursor string
|
||||
hasMore bool
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
results any
|
||||
paging *sqls.Paging
|
||||
}
|
||||
|
||||
type localizedError interface {
|
||||
Message(locale string) string
|
||||
}
|
||||
|
||||
func CursorData(results any, cursor string, hasMore bool) any {
|
||||
return cursorData{results: results, cursor: cursor, hasMore: hasMore}
|
||||
}
|
||||
|
||||
func PageData(results any, paging *sqls.Paging) any {
|
||||
return pageData{results: results, paging: paging}
|
||||
}
|
||||
|
||||
func WriteJSON(ctx *gin.Context, result any) {
|
||||
ctx.JSON(http.StatusOK, buildJSONResult(ctx, result))
|
||||
}
|
||||
|
||||
func WriteHttpStatusJSON(ctx *gin.Context, statusCode int, result any) {
|
||||
ctx.JSON(statusCode, buildJSONResult(ctx, result))
|
||||
}
|
||||
|
||||
func buildJSONResult(ctx *gin.Context, result any) *web.JsonResult {
|
||||
switch value := result.(type) {
|
||||
case nil:
|
||||
return web.JsonSuccess()
|
||||
case *web.JsonResult:
|
||||
return value
|
||||
case web.JsonResult:
|
||||
return &value
|
||||
case *web.CodeError:
|
||||
return web.JsonError(value)
|
||||
case web.CodeError:
|
||||
return web.JsonError(&value)
|
||||
case *errorsx.I18nError:
|
||||
return value.JsonResult(i18nx.Locale(ctx))
|
||||
case errorsx.I18nError:
|
||||
return value.JsonResult(i18nx.Locale(ctx))
|
||||
case localizedError:
|
||||
return web.JsonErrorMsg(value.Message(i18nx.Locale(ctx)))
|
||||
case error:
|
||||
return web.JsonError(value)
|
||||
case cursorData:
|
||||
return web.JsonCursorData(value.results, value.cursor, value.hasMore)
|
||||
case pageData:
|
||||
return web.JsonPageData(value.results, value.paging)
|
||||
case web.RspBuilder:
|
||||
return value.JsonResult()
|
||||
case *web.RspBuilder:
|
||||
return value.JsonResult()
|
||||
default:
|
||||
return web.JsonData(result)
|
||||
}
|
||||
}
|
||||
104
internal/pkg/httpx/response_test.go
Normal file
104
internal/pkg/httpx/response_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func TestWriteJSONWrapsCommonResultTypes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
wantStatus int
|
||||
want web.JsonResult
|
||||
}{
|
||||
{
|
||||
name: "nil becomes success",
|
||||
input: nil,
|
||||
wantStatus: http.StatusOK,
|
||||
want: *web.JsonSuccess(),
|
||||
},
|
||||
{
|
||||
name: "plain value becomes data",
|
||||
input: map[string]any{"id": float64(1)},
|
||||
wantStatus: http.StatusOK,
|
||||
want: *web.JsonData(map[string]any{"id": float64(1)}),
|
||||
},
|
||||
{
|
||||
name: "error becomes json error",
|
||||
input: errors.New("boom"),
|
||||
wantStatus: http.StatusOK,
|
||||
want: *web.JsonError(errors.New("boom")),
|
||||
},
|
||||
{
|
||||
name: "page data becomes page result",
|
||||
input: PageData([]any{"a"}, &sqls.Paging{Page: 1, Limit: 20, Total: 1}),
|
||||
wantStatus: http.StatusOK,
|
||||
want: *web.JsonPageData([]any{"a"}, &sqls.Paging{Page: 1, Limit: 20, Total: 1}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, recorder := testContext()
|
||||
|
||||
WriteJSON(ctx, tt.input)
|
||||
|
||||
if recorder.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus)
|
||||
}
|
||||
var got web.JsonResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Success != tt.want.Success || got.ErrorCode != tt.want.ErrorCode || got.Message != tt.want.Message {
|
||||
t.Fatalf("result = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONLocalizesI18nErrors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, recorder := testContext()
|
||||
i18nx.SetLocale(ctx, i18nx.LocaleEnUS)
|
||||
|
||||
WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0116"))
|
||||
|
||||
var got web.JsonResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Message != "Conversation not found." {
|
||||
t.Fatalf("message = %q, want %q", got.Message, "Conversation not found.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHttpStatusJSONUsesProvidedStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, recorder := testContext()
|
||||
|
||||
WriteHttpStatusJSON(ctx, http.StatusUnauthorized, web.JsonErrorMsg("unauthorized"))
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func testContext() (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
return ctx, recorder
|
||||
}
|
||||
27
internal/pkg/i18nx/context.go
Normal file
27
internal/pkg/i18nx/context.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package i18nx
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const contextLocaleKey = "i18nx.locale"
|
||||
|
||||
func Locale(ctx *gin.Context) string {
|
||||
if ctx == nil {
|
||||
return DefaultLocale
|
||||
}
|
||||
value, ok := ctx.Get(contextLocaleKey)
|
||||
if !ok {
|
||||
return DefaultLocale
|
||||
}
|
||||
locale, ok := value.(string)
|
||||
if !ok {
|
||||
return DefaultLocale
|
||||
}
|
||||
return NormalizeLocale(locale)
|
||||
}
|
||||
|
||||
func SetLocale(ctx *gin.Context, locale string) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user