diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..afae20f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# AgentDesk single-merchant deployment environment template. +# Copy to .env.production for each merchant and keep that file out of git. +# +# Generate secrets with commands such as: +# openssl rand -base64 24 +# openssl rand -base64 32 + +# Required: first admin password used by bootstrap login. +# Do not use ChangeMe123! in production. +AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD= + +# Required: signing secret for customer chat sessions. +AGENT_DESK_CUSTOMERSESSION_SECRET= + +# Required when using docker-compose.yml or docker-compose.lancedb.yml MySQL service. +AGENT_DESK_MYSQL_PASSWORD= +AGENT_DESK_MYSQL_ROOT_PASSWORD= + +# Optional: use an external merchant database instead of the compose MySQL service. +# Leave empty when using the compose default MySQL service. +# Example: +# AGENT_DESK_DB_DSN=user:password@tcp(mysql-host:3306)/merchant_db?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local +AGENT_DESK_DB_DSN= + +# Optional but recommended: outbound notification webhook for high-intent leads, +# appointment leads, conversation assignment, and human-handoff alerts. +AGENT_DESK_NOTIFY_WEBHOOK_ENABLED=false +AGENT_DESK_NOTIFY_WEBHOOK_URL= +AGENT_DESK_NOTIFY_WEBHOOK_FORMAT=generic +AGENT_DESK_NOTIFY_WEBHOOK_SECRET= +AGENT_DESK_NOTIFY_DAILYREPORT_ENABLED=false +AGENT_DESK_NOTIFY_DAILYREPORT_CRON="0 9 * * *" +AGENT_DESK_NOTIFY_DAILYREPORT_DATEOFFSETDAYS=0 +AGENT_DESK_NOTIFY_DAILYREPORT_ALLOWDUPLICATE=false + +# Optional: acceptance script connection info. +AGENT_DESK_BASE_URL=http://127.0.0.1:8083 +AGENT_DESK_ADMIN_USERNAME=admin +AGENT_DESK_ADMIN_PASSWORD= +MUSE_ACCEPTANCE_TIMEOUT_MS=70000 diff --git a/.gitignore b/.gitignore index fd0b2836..995caff7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ data/ config/config.yaml include/ lib/ +!web/lib/ +!web/lib/api/ +!web/lib/api/config.ts dist/ __debug_bin* @@ -18,4 +21,4 @@ node_modules/ agent-desk -test-reports \ No newline at end of file +test-reports diff --git a/cmd/testdata/agentteam/init.go b/cmd/testdata/agentteam/init.go index bb1d68ca..a88e6aea 100644 --- a/cmd/testdata/agentteam/init.go +++ b/cmd/testdata/agentteam/init.go @@ -122,7 +122,7 @@ func createOrGetUser(ctx *sqls.TxContext, username, nickname string) (int64, boo } // 创建新用户 - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(constants.BootstrapAdminPassword), bcrypt.DefaultCost) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(constants.BootstrapAdminInitialPassword()), bcrypt.DefaultCost) if err != nil { return 0, false, err } diff --git a/config/config.example.yaml b/config/config.example.yaml index 58fe9040..8b8ea7ac 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -105,6 +105,35 @@ mcp: # Extra HTTP headers sent to this MCP server on every request, for example Authorization or tenant headers. headers: {} +notify: + webhook: + # Generic outbound webhook for high-intent leads, appointment leads, and human-handoff/assignment alerts. + # Keep disabled unless a merchant-specific endpoint is configured. + enabled: false + # Endpoint examples: a self-hosted CRM receiver, n8n webhook, WeCom group robot, DingTalk robot, or Feishu bot. + url: "" + # Supported values: + # - generic/json: {"eventType","title","content","text","metadata","timestamp"} + # - wecom_robot/dingtalk/text: {"msgtype":"text","text":{"content":"..."}} + # - feishu/lark: {"msg_type":"text","content":{"text":"..."}} + format: generic + # Optional HMAC-SHA256 signing secret for self-hosted receivers. + # When set, requests include X-Agent-Desk-Timestamp and X-Agent-Desk-Signature headers. + secret: "" + # Request timeout in milliseconds. Values <= 0 fall back to 5000. + timeoutMs: 5000 + # Optional static headers, for example Authorization for a self-hosted receiver. + headers: {} + dailyReport: + # Whether to send the daily business report to the configured notify.webhook on a schedule. + enabled: false + # Standard 5-field cron expression. Default: 09:00 every day. + cron: "0 9 * * *" + # 0 sends today's report; -1 sends yesterday's report. + dateOffsetDays: 0 + # false prevents the scheduled job from sending the same report date more than once. + allowDuplicate: false + oidc: # Whether to enable OIDC login. This system acts as the OIDC client. enabled: false diff --git a/docker-compose.lancedb.yml b/docker-compose.lancedb.yml index 007242af..7b362fd9 100644 --- a/docker-compose.lancedb.yml +++ b/docker-compose.lancedb.yml @@ -5,8 +5,8 @@ services: environment: MYSQL_DATABASE: cs_ai_agent MYSQL_USER: cs_ai_agent - MYSQL_PASSWORD: cs_ai_agent_password - MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password + MYSQL_PASSWORD: "${AGENT_DESK_MYSQL_PASSWORD:-cs_ai_agent_password}" + MYSQL_ROOT_PASSWORD: "${AGENT_DESK_MYSQL_ROOT_PASSWORD:-cs_ai_agent_root_password}" TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 @@ -38,6 +38,13 @@ services: - ./docker/agent-desk-lancedb.yaml:/app/config/config.yaml:ro environment: TZ: Asia/Shanghai + AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD: "${AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD:-}" + AGENT_DESK_CUSTOMERSESSION_SECRET: "${AGENT_DESK_CUSTOMERSESSION_SECRET:-}" + AGENT_DESK_DB_DSN: "${AGENT_DESK_DB_DSN:-cs_ai_agent:${AGENT_DESK_MYSQL_PASSWORD:-cs_ai_agent_password}@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local}" + AGENT_DESK_NOTIFY_WEBHOOK_ENABLED: "${AGENT_DESK_NOTIFY_WEBHOOK_ENABLED:-false}" + AGENT_DESK_NOTIFY_WEBHOOK_URL: "${AGENT_DESK_NOTIFY_WEBHOOK_URL:-}" + AGENT_DESK_NOTIFY_WEBHOOK_FORMAT: "${AGENT_DESK_NOTIFY_WEBHOOK_FORMAT:-generic}" + AGENT_DESK_NOTIFY_WEBHOOK_SECRET: "${AGENT_DESK_NOTIFY_WEBHOOK_SECRET:-}" volumes: mysql-data: diff --git a/docker-compose.sqlite-lancedb.yml b/docker-compose.sqlite-lancedb.yml index 49d063f7..b826b7aa 100644 --- a/docker-compose.sqlite-lancedb.yml +++ b/docker-compose.sqlite-lancedb.yml @@ -9,3 +9,9 @@ services: - ./data:/app/data environment: TZ: Asia/Shanghai + AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD: "${AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD:-}" + AGENT_DESK_CUSTOMERSESSION_SECRET: "${AGENT_DESK_CUSTOMERSESSION_SECRET:-}" + AGENT_DESK_NOTIFY_WEBHOOK_ENABLED: "${AGENT_DESK_NOTIFY_WEBHOOK_ENABLED:-false}" + AGENT_DESK_NOTIFY_WEBHOOK_URL: "${AGENT_DESK_NOTIFY_WEBHOOK_URL:-}" + AGENT_DESK_NOTIFY_WEBHOOK_FORMAT: "${AGENT_DESK_NOTIFY_WEBHOOK_FORMAT:-generic}" + AGENT_DESK_NOTIFY_WEBHOOK_SECRET: "${AGENT_DESK_NOTIFY_WEBHOOK_SECRET:-}" diff --git a/docker-compose.yml b/docker-compose.yml index cd43fe4f..cf91e4d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,8 @@ services: environment: MYSQL_DATABASE: cs_ai_agent MYSQL_USER: cs_ai_agent - MYSQL_PASSWORD: cs_ai_agent_password - MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password + MYSQL_PASSWORD: "${AGENT_DESK_MYSQL_PASSWORD:-cs_ai_agent_password}" + MYSQL_ROOT_PASSWORD: "${AGENT_DESK_MYSQL_ROOT_PASSWORD:-cs_ai_agent_root_password}" TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 @@ -46,6 +46,13 @@ services: - ./docker/agent-desk.yaml:/app/config/config.yaml:ro environment: TZ: Asia/Shanghai + AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD: "${AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD:-}" + AGENT_DESK_CUSTOMERSESSION_SECRET: "${AGENT_DESK_CUSTOMERSESSION_SECRET:-}" + AGENT_DESK_DB_DSN: "${AGENT_DESK_DB_DSN:-cs_ai_agent:${AGENT_DESK_MYSQL_PASSWORD:-cs_ai_agent_password}@tcp(mysql:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local}" + AGENT_DESK_NOTIFY_WEBHOOK_ENABLED: "${AGENT_DESK_NOTIFY_WEBHOOK_ENABLED:-false}" + AGENT_DESK_NOTIFY_WEBHOOK_URL: "${AGENT_DESK_NOTIFY_WEBHOOK_URL:-}" + AGENT_DESK_NOTIFY_WEBHOOK_FORMAT: "${AGENT_DESK_NOTIFY_WEBHOOK_FORMAT:-generic}" + AGENT_DESK_NOTIFY_WEBHOOK_SECRET: "${AGENT_DESK_NOTIFY_WEBHOOK_SECRET:-}" volumes: mysql-data: diff --git a/docker/agent-desk-lancedb.yaml b/docker/agent-desk-lancedb.yaml index 9fdb6439..1b9f776f 100644 --- a/docker/agent-desk-lancedb.yaml +++ b/docker/agent-desk-lancedb.yaml @@ -55,6 +55,15 @@ mcp: timeoutMs: 60000 headers: {} +notify: + webhook: + enabled: false + url: "" + format: generic + secret: "" + timeoutMs: 5000 + headers: {} + wxWork: enabled: false diff --git a/docker/agent-desk-sqlite-lancedb.yaml b/docker/agent-desk-sqlite-lancedb.yaml index 95b4eb77..08ad909e 100644 --- a/docker/agent-desk-sqlite-lancedb.yaml +++ b/docker/agent-desk-sqlite-lancedb.yaml @@ -64,6 +64,15 @@ mcp: timeoutMs: 60000 headers: {} +notify: + webhook: + enabled: false + url: "" + format: generic + secret: "" + timeoutMs: 5000 + headers: {} + wxWork: enabled: false corpId: "" diff --git a/docker/agent-desk.yaml b/docker/agent-desk.yaml index cce0540d..65a1d269 100644 --- a/docker/agent-desk.yaml +++ b/docker/agent-desk.yaml @@ -66,6 +66,15 @@ mcp: timeoutMs: 15000 headers: {} +notify: + webhook: + enabled: false + url: "" + format: generic + secret: "" + timeoutMs: 5000 + headers: {} + wxWork: enabled: false corpId: "" diff --git a/go.mod b/go.mod index b9535c2c..880ddfb1 100644 --- a/go.mod +++ b/go.mod @@ -33,21 +33,23 @@ require ( golang.org/x/crypto v0.53.0 golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.38.0 golang.org/x/tools v0.46.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 + gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/text v0.38.0 // indirect ) require ( diff --git a/go.sum b/go.sum index f0c029ba..c39dbe7a 100644 --- a/go.sum +++ b/go.sum @@ -17,24 +17,16 @@ github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fT github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw= github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= @@ -44,20 +36,12 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= -github.com/cloudwego/eino v0.8.7 h1:GlzrJa5hpovPdZ+loBvXs1W4D5gWC+a6PRqJmlj4iis= -github.com/cloudwego/eino v0.8.7/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= github.com/cloudwego/eino v0.9.6 h1:M3IRhIpDxNwIuQ2SRUX1yUTkC8lcMggRI5wtHZy8A5M= github.com/cloudwego/eino v0.9.6/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= -github.com/cloudwego/eino-ext/components/model/openai v0.1.11 h1:juf9kECfmxJBA0rJSxDT7XOUSgrbMHaGHgBwd06lYaI= -github.com/cloudwego/eino-ext/components/model/openai v0.1.11/go.mod h1:DBk44Dq1mhuoAacdUzzhZhSGeeBECDI2rIZnJFeVZoE= github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.15 h1:LbdSG9+qWzzp9RFW6dSFkaUW171JvCoYn/K63zX6dQE= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.15/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= @@ -172,7 +156,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -194,8 +177,6 @@ github.com/lancedb/lancedb-go v0.1.2 h1:ucM+KNN5J886OilSh4MRdyBa1sinHyrisoaswNIS github.com/lancedb/lancedb-go v0.1.2/go.mod h1:HzleylKfuw2HgfBBfrE3tb4LMKNdJ3/TQ1Ziyd+CLZk= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -204,8 +185,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= -github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/meguminnnnnnnnn/go-openai v0.1.5 h1:K9XFfnEUj9E+9djustmfa4eIdg8Q2vWD4mGv+AHbQ2k= github.com/meguminnnnnnnnn/go-openai v0.1.5/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -250,8 +229,6 @@ github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHP github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= @@ -382,8 +359,6 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -391,19 +366,13 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -413,8 +382,6 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -422,8 +389,6 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -445,23 +410,16 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -469,8 +427,6 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/ai/runtime/graphs/handoff_graph_test.go b/internal/ai/runtime/graphs/handoff_graph_test.go index e4887f46..e9a66c6f 100644 --- a/internal/ai/runtime/graphs/handoff_graph_test.go +++ b/internal/ai/runtime/graphs/handoff_graph_test.go @@ -114,8 +114,13 @@ func setupHandoffGraphTestDB(t *testing.T) *gorm.DB { &models.Conversation{}, &models.ConversationEventLog{}, &models.ConversationReadState{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.Channel{}, &models.Message{}, &models.ChannelMessageOutbox{}, + &models.SalesLead{}, + &models.LeadFollowUp{}, ); err != nil { t.Fatalf("auto migrate error = %v", err) } diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index f38c25a0..54ddf4e3 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -10,6 +10,7 @@ import ( "agent-desk/internal/ai/runtime/registry" "agent-desk/internal/ai/runtime/tooling" "agent-desk/internal/models" + "agent-desk/internal/services" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/tool" @@ -71,6 +72,9 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, input Buil } allTools := make([]tool.BaseTool, 0, len(input.StaticTools)) allTools = append(allTools, input.StaticTools...) + if runtimeInstruction := services.DigitalStoreProfileService.BuildRuntimeInstruction(); runtimeInstruction != "" { + input.AIAgent.SystemPrompt = strings.TrimSpace(input.AIAgent.SystemPrompt + "\n\n" + runtimeInstruction) + } instructionResult := f.instructionService.Build(input.AIAgent, nil, input.InstructionToolDefinitions, input.StaticToolCodes) handlers := make([]adk.ChatModelAgentMiddleware, 0, 3) builtHandlers, err := f.handlerService.Build(ctx, BuildAgentHandlersInput{ diff --git a/internal/ai/runtime/reply_commit_service.go b/internal/ai/runtime/reply_commit_service.go index 6bd78a43..5f7025bf 100644 --- a/internal/ai/runtime/reply_commit_service.go +++ b/internal/ai/runtime/reply_commit_service.go @@ -31,7 +31,14 @@ func newReplyCommitService() *replyCommitService { } func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) { - replyText := strings.TrimSpace(input.ReplyText) + replyText := tailorDigitalStoreFallback(input.ReplyText, input.Message.Content) + replyText = sanitizeCommercialReplyText(replyText) + replyText = ensureConcretePlanWhenAsked(replyText, input.Message.Content) + replyText = ensureLowBudgetPath(replyText, input.Message.Content) + replyText = sanitizeReplyAgainstCustomerContext(replyText, input.Conversation.ID) + replyText = sanitizeStoreMentionAgainstCustomerContext(replyText, input.Conversation.ID) + replyText = sanitizeReplyAgainstKnownLeadInfo(replyText, input.Conversation.ID) + replyText = sanitizeChitchatReply(replyText, input.Message.Content) if replyText == "" { return nil, nil } @@ -80,3 +87,307 @@ func (s *replyCommitService) buildAIPrincipal(aiAgent models.AIAgent) *dto.AuthP Nickname: username, } } + +func sanitizeCommercialReplyText(value string) string { + text := strings.TrimSpace(value) + if text == "" { + return "" + } + replacer := strings.NewReplacer( + "已为您预留时段,周六见!", "我已记录这条到店意向,具体时段以门店顾问确认为准。", + "已为您预留时段", "我已记录这条到店意向,具体时段以门店顾问确认为准", + "已为您预留", "我已记录,具体安排以门店顾问确认为准", + "预约成功", "预约信息已记录,待门店顾问确认", + "绝不会白跑一趟", "建议先明确重点体验清单,减少白跑", + "确保您到店就能好好试躺", "具体体验安排以门店顾问确认为准", + "帮您预约周末试躺时段", "帮您记录周末试躺意向,待门店顾问确认时段", + "帮您预约个周末试躺", "帮您记录周末试躺意向,待门店顾问确认", + "预约周末试躺可以提前帮您留好专属时段", "周末试躺意向可以先记录,具体时段待门店顾问确认", + "预约周末试躺", "记录周末试躺意向", + "我帮您留个时段", "我帮您记录到店意向,具体时段待门店顾问确认", + "周六下午两点后到店都可以", "周六下午的到店意向我先帮您记录,具体时段待门店顾问确认", + "帮你留好周末的体验时段和礼包", "帮你记录到店意向,具体体验时段和活动权益待门店顾问确认", + "帮您留好周末的体验时段和礼包", "帮您记录到店意向,具体体验时段和活动权益待门店顾问确认", + "帮你留好周末的体验时段", "帮你记录到店意向,具体体验时段待门店顾问确认", + "帮您留好周末的体验时段", "帮您记录到店意向,具体体验时段待门店顾问确认", + "留好周末的体验时段", "记录到店意向,具体体验时段待门店顾问确认", + "留好体验时段", "记录体验意向", + "保留体验时段", "确认体验时段", + "预留体验时段", "确认体验时段", + "第一时间联系您", "结合门店安排联系您", + "稍后安排门店顾问添加您", "会转给门店顾问确认微信联系", + "安排专人给您做介绍", "转给门店顾问确认体验安排", + "完全没问题", "我先帮您记录,具体安排以门店顾问确认为准", + "3点没问题", "3点的到店意向我先帮您记录,具体时段以门店顾问确认为准", + "下午3点没问题", "下午3点的到店意向我先帮您记录,具体时段以门店顾问确认为准", + "周六下午3点没问题", "周六下午3点的到店意向我先帮您记录,具体时段以门店顾问确认为准", + "我帮您安排顾问优先留好试躺位", "我会转给门店顾问确认体验安排", + "到店前顾问会跟您确认时段", "后续由门店顾问确认具体时段", + "也不用担心滑落", "建议到店重点体验起身稳定性", + "老人一看就会按", "按键是否顺手建议让老人现场试一下", + "一看就会", "建议现场试一下是否顺手", + "安全性是有保障的", "安全细节建议到店结合实物确认", + "减少摔倒风险", "降低起身负担", + "缓解腰部压力", "帮助调整到更舒适的姿势", + "减轻腰椎负担", "提升休息姿势的舒适度", + "能很好地托住腰背", "重点看腰部承托和贴合感", + "很适合老人家使用", "适合带老人到店试躺对比", + "马上安排", "会转给", + "立即安排", "会转给", + "我会安排门店顾问在您到店前做好准备", "会转给门店顾问确认到店安排", + "我马上安排专人联系您", "我会转给门店顾问确认", + "我马上帮您解决", "我先帮您记录并转给售后顾问确认", + "我这就马上帮您登记售后诉求来处理", "我先帮您记录售后诉求并转给售后顾问确认", + "我马上帮您登记", "我先帮您记录并转给售后顾问确认", + "我马上帮您登记转人工", "我先帮您记录并转人工确认", + "安排售后专员直接跟您对接处理", "转给售后顾问确认处理方式", + "直接安排售后顾问联系您确认上门检测时间", "转给售后顾问确认检测方式", + "转给门店顾问尽快确认", "转给门店顾问确认", + "我记录后尽快安排对接", "我记录后转给售后顾问确认", + "会尽快给您一个反馈", "会转给售后顾问确认反馈方式", + "如果是质量问题我们一定负责处理", "是否属于质量问题及处理方式需以售后检测和订单条款确认为准", + "一定尽快解决问题", "继续跟进确认处理方式", + "会立刻记录您的诉求", "会记录您的诉求", + "彻底帮您解决", "继续帮您跟进确认", + "很多人一开始也会这么问", "这个问题很常见", + "很多人一躺就觉得", "试躺时可以重点感受", + "很多怕软又怕硬的顾客试过都觉得刚好", "怕软又怕硬的话,建议重点感受支撑和表层舒适度是否平衡", + "很多家庭选它是因为", "从产品定位看,它的特点是", + "很多重视腰背支撑的家庭都选它", "从产品定位看,它更偏支撑承托", + "很多朋友纠结的点", "比较常见的顾虑", + "很多家庭到店首选", "建议到店重点试躺", + "很多老顾客反馈", "建议到店试躺确认", + "很多客人反馈", "从产品定位看", + "很多客人试过都说", "试躺时可以重点感受", + "很多对腰背有要求的人试下来都说", "从产品定位看", + "按摩功能", "升降功能", + "有的,慕斯智能电动床带头脚升降功能", "它主要是头脚升降功能", + "有的,慕斯智能电动床", "慕斯智能电动床核心是头脚升降", + "有的。慕斯智能电动床带有多模式调节功能", "慕斯智能电动床核心是头脚升降和不同休息角度调节,是否有其他功能以门店实物确认为准", + "有的。慕斯智能电动床", "慕斯智能电动床核心是头脚升降", + "单独换个合适的枕头效果就很明显", "单独先换合适的枕头也可能有帮助,建议试枕确认", + "肩颈腰都能放松", "有助于提升肩颈和腰部的承托、释压体验", + "放松颈椎", "改善颈肩支撑体验", + "颈椎放松", "颈肩支撑", + "减轻腰部的压力", "改善睡眠时的受力感受", + "减少腰部的压力", "改善睡眠时的受力感受", + "减少平躺时腰椎悬空的问题", "帮助观察平躺时腰部贴合是否更稳定", + "改善睡姿受力", "改善睡眠时的支撑感受", + "头、肩、腰、臀、腿五个区域做了不同硬度的弹簧排布", "不同区域做了承托差异设计", + "头、肩、腰、臀、腿五个区域", "不同承托区域", + "腰臀部位支撑力更强", "腰臀区域更强调承托", + "弹簧线径更粗、支撑力更强", "腰部区域更强调承托", + "弹簧线径更粗", "腰部区域更强调承托", + "高密度冷泡棉", "舒适承托材料", + "2cm厚的乳胶", "舒适释压层", + "不会硬推高价品", "可以按预算范围推荐,并尊重您的选择", + "李叔您好", "李先生您好", + "李女士/先生,您好。", "您好,", + "睡眠顾问小眠", "慕小眠", + "老人起床时轻轻按个键,背部缓缓抬起,省力又安全", "老人可以现场体验抬背起身角度和按键手感,是否顺手以实际试用为准", + "省力又安全", "更方便体验起身角度", + "不容易睡塌", "支撑稳定性建议试躺确认", + "太好了!", "好的,", + "(您在上海的话,也欢迎到店做排骨架检测确认问题,方便时也可以留一下电话)", "也可以补充订单号、型号和异响位置,方便售后确认。", + "到店也是一样的", "到店会以门店当天活动和顾问确认为准", + "不会到店再变", "具体活动和最终成交价以门店确认为准", + "门店所有产品都是明码标价", "门店产品会按标价和活动规则说明", + "部分活动款支持体验期", "是否有体验权益需由门店顾问按活动和订单条款确认", + "要求床垫无污损、无折痕", "具体条件以订单条款为准", + "帮您提前留好时段", "帮您记录到店意向,具体时段待门店顾问确认", + "护脊效果好", "更强调支撑承托", + "脊护效果好", "更强调支撑承托", + ) + return strings.TrimSpace(replacer.Replace(text)) +} + +func ensureLowBudgetPath(replyText string, customerContent string) string { + text := strings.TrimSpace(replyText) + content := strings.TrimSpace(customerContent) + if text == "" || !mentionsLowBudget(content) { + return text + } + if containsAny(text, "三四千", "样品", "活动款", "先确认预算是否匹配") { + return text + } + return text + "\n\n如果预算最多三四千,我会先把预期说清楚:慕斯常规床垫主力款大多会高于这个范围,建议优先问门店是否有样品、阶段活动款或更基础配置;如果没有匹配款,也可以先到店只做试躺对比,暂不下单。" +} + +func mentionsLowBudget(value string) bool { + text := strings.TrimSpace(value) + return containsAny(text, "三四千", "3-4千", "3000", "4000", "三千", "四千") || + (strings.Contains(text, "预算") && containsAny(text, "很低", "有限", "紧", "不高")) +} + +func tailorDigitalStoreFallback(replyText string, customerContent string) string { + text := strings.TrimSpace(replyText) + content := strings.TrimSpace(customerContent) + if text == "" || !isGenericDigitalStoreFallback(text) { + return text + } + switch { + case containsAny(content, "颈肩", "脖子", "枕头", "t10", "T10"): + return "颈肩不舒服可以先从枕高和颈肩承托看起,不一定要和床垫成套买。慕斯T10释压枕偏分区承托、慢回弹释压,适合侧睡较多、颈肩紧或想调整枕头高度的人先试枕确认。预算有限的话,可以先试枕头,再看床垫是否也需要调整。" + case containsAny(content, "老人", "起夜", "起身", "电动床"): + return "老人起夜多、起身困难,可以重点了解慕斯智能电动床的头脚升降功能,先看起身角度、遥控按键是否顺手、床垫适配和安全细节。具体配置、库存、活动和体验时段需要门店顾问确认,建议带老人到店实际试一下。" + case containsAny(content, "护脊", "噱头", "腰", "背", "治好", "治疗"): + return "护脊不要只听概念,主要看支撑和贴合:仰卧时腰部是否悬空,侧卧时肩臀是否压迫,翻身是否费力。床垫不能替代医疗诊断或治疗,也不能保证治好腰背问题;如果持续疼痛建议先咨询医生,到店可重点对比脊护支撑款和云感舒睡款。" + case containsAny(content, "预算", "贵", "1.8", "最低", "便宜", "方案"): + return "先给您一个可对比的方向:1.8米如果预算约8000-13000元,可以看云感舒睡款,偏柔和包裹;预算约12000-18000元,可以看脊护支撑款,偏支撑承托。最终价格、库存和活动需要门店顾问确认,您可以先告诉我偏软还是偏硬。" + case containsAny(content, "售后", "异响", "投诉", "咯吱", "退"): + return "真的抱歉影响您休息了。异响原因需要结合订单、产品型号、床架/排骨架和现场情况确认,我先帮您记录售后诉求并转人工顾问;退换或赔付需要以订单条款和售后检测结果为准。" + } + return "我在的。您可以直接说预算、尺寸、使用人群或当前睡眠困扰,我会先给可执行的产品方向;涉及最终价格、库存、活动、退换或售后结论,再由门店顾问确认。" +} + +func isGenericDigitalStoreFallback(text string) bool { + return containsAny(text, + "你的问题我已经记录", + "你问的问题我已经记录", + "涉及最终价格、库存、退换货、售后争议或医疗效果时", + "会安排门店顾问进一步确认", + ) +} + +func ensureConcretePlanWhenAsked(replyText string, customerContent string) string { + text := strings.TrimSpace(replyText) + content := strings.TrimSpace(customerContent) + if text == "" || !containsAny(content, "什么方案", "啥方案", "给我方案", "具体方案") { + return text + } + if containsAny(text, "云感", "脊护", "电动床", "方案一", "方案二", "8000", "12000") { + return text + } + return "先给您一个当前可执行方案:1.8米可以先看两种方向,云感舒睡款偏柔和包裹,价格大致8000-13000元;脊护支撑款偏支撑承托,价格大致12000-18000元。库存、到店活动和最终成交价需要门店顾问确认。您只需要再确认偏软还是偏硬,我就能帮您把范围缩到一款。" +} + +func sanitizeChitchatReply(replyText string, customerContent string) string { + text := strings.TrimSpace(replyText) + content := strings.TrimSpace(customerContent) + if text == "" || !containsAny(content, "写诗", "闲聊", "聊天", "没反应", "在吗", "听得懂") { + return text + } + replacer := strings.NewReplacer( + "抱歉让您久等了,我一直都在。您之前提到睡眠方面有些困扰,能再具体说说是什么情况吗?比如是腰背不舒服,还是觉得现在床垫太软太硬?我帮您分析分析,看看哪款更适合您。", "抱歉让您久等了,我在的。您想继续随便看看,还是我直接帮您按预算和睡感筛两款?", + "您之前提到睡眠方面有些困扰", "如果您想看床垫", + "比如是腰背不舒服,还是觉得现在床垫太软太硬?", "可以从预算、偏软偏硬、给谁使用这几个点看。", + "有没有腰酸背累或者床垫不舒服的情况?", "如果想聊睡眠或床垫,我也可以帮您看看。", + "有没有腰酸背累", "有没有想改善的睡眠体验", + "跟我说说,我帮您分析分析。", "您想随便看看还是了解某一类产品?", + "写诗我可不太擅长", "可以简单来一句", + ) + return strings.TrimSpace(replacer.Replace(text)) +} + +func sanitizeReplyAgainstCustomerContext(replyText string, conversationID int64) string { + text := strings.TrimSpace(replyText) + if text == "" || conversationID <= 0 || customerHistoryMentionsBackConcern(conversationID) { + return text + } + replacer := strings.NewReplacer( + "腰背不适的话,", "", + "腰背不舒服,", "", + "腰背不舒服", "支撑感", + "有没有腰酸背疼或者喜欢侧睡?", "平时更习惯侧睡还是仰睡?", + "有没有腰酸背疼", "有没有想改善的睡眠感受", + "腰背很友好", "支撑承托更明确", + "腰疼的话,", "", + "如果腰疼,", "如果关注腰背支撑,", + "如果您腰背不适,", "如果您关注支撑感,", + "结合您提到腰疼的情况", "如果您关注腰背支撑", + "您提到腰疼", "您关注腰背支撑", + "您这样需要针对性支撑的情况", "想要更强承托的情况", + "适合您这样需要针对性支撑的情况", "适合想要更强承托的人群", + "对腰背压力大的人群", "对关注腰背支撑的人群", + ) + return strings.TrimSpace(replacer.Replace(text)) +} + +func sanitizeReplyAgainstKnownLeadInfo(replyText string, conversationID int64) string { + text := strings.TrimSpace(replyText) + if text == "" || conversationID <= 0 || !customerHistoryHasPhone(conversationID) { + return text + } + replacer := strings.NewReplacer( + "麻烦您再留一下您的姓名和手机号,方便顾问提前帮您安排体验和礼包~", "我会把这些信息转给门店顾问,后续由顾问确认具体时段和体验安排。", + "方便留一下您的姓名和手机号吗?", "我先按当前信息记录,后续由门店顾问确认。", + "方便留个姓名和手机号吗?", "我先按当前信息记录,后续由门店顾问确认。", + "方便留个姓名和电话吗?", "我先按当前信息记录,后续由门店顾问确认。", + "方便留下姓名和手机号吗?", "我先按当前信息记录,后续由门店顾问确认。", + "方便留个联系方式吗?", "我会使用您已提供的联系方式转给门店顾问确认。", + "方便留一下联系方式吗?", "我会使用您已提供的联系方式转给门店顾问确认。", + "您可以留下手机号", "我已看到您提供的手机号", + ) + return strings.TrimSpace(replacer.Replace(text)) +} + +func sanitizeStoreMentionAgainstCustomerContext(replyText string, conversationID int64) string { + text := strings.TrimSpace(replyText) + if text == "" || conversationID <= 0 || customerHistoryMentionsStoreArea(conversationID) { + return text + } + replacer := strings.NewReplacer( + "徐汇门店", "门店", + "徐汇店", "门店", + "上海徐汇", "所在城市", + ) + return strings.TrimSpace(replacer.Replace(text)) +} + +func customerHistoryMentionsBackConcern(conversationID int64) bool { + messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("sender_type = ?", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(20)) + for _, message := range messages { + if containsAny(message.Content, "腰", "背", "酸", "疼", "痛", "僵", "护脊", "支撑") { + return true + } + } + return false +} + +func customerHistoryHasPhone(conversationID int64) bool { + messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("sender_type = ?", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(30)) + for _, message := range messages { + if hasMainlandPhone(message.Content) { + return true + } + } + return false +} + +func customerHistoryMentionsStoreArea(conversationID int64) bool { + messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("sender_type = ?", enums.IMSenderTypeCustomer). + Desc("id"). + Limit(30)) + for _, message := range messages { + if containsAny(message.Content, "徐汇", "上海", "门店", "到店", "试躺", "周六", "周日", "周末", "预约") { + return true + } + } + return false +} + +func hasMainlandPhone(value string) bool { + digits := make([]rune, 0, 16) + for _, r := range value { + if r >= '0' && r <= '9' { + digits = append(digits, r) + } + } + text := string(digits) + for i := 0; i+11 <= len(text); i++ { + if text[i] == '1' && text[i+1] >= '3' && text[i+1] <= '9' { + return true + } + } + return false +} diff --git a/internal/ai/runtime/reply_commit_service_test.go b/internal/ai/runtime/reply_commit_service_test.go index 54198a96..50f40c90 100644 --- a/internal/ai/runtime/reply_commit_service_test.go +++ b/internal/ai/runtime/reply_commit_service_test.go @@ -46,6 +46,181 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) { } } +func TestSanitizeCommercialReplyTextDowngradesRiskyCommitments(t *testing.T) { + got := sanitizeCommercialReplyText("很多重视腰背支撑的家庭都选它。已为您预留时段,周六见!预约周末试躺可以提前帮您留好专属时段。顾问会第一时间联系您,还能体验按摩功能。周六下午3点没问题,也不用担心滑落。老人一看就会按。") + for _, banned := range []string{"很多重视腰背支撑", "已为您预留", "专属时段", "第一时间", "按摩功能", "没问题", "不用担心滑落", "一看就会按"} { + if strings.Contains(got, banned) { + t.Fatalf("reply still contains risky wording %q: %s", banned, got) + } + } + for _, want := range []string{"产品定位", "具体时段以门店顾问确认为准", "门店顾问确认", "升降功能", "现场试一下"} { + if !strings.Contains(got, want) { + t.Fatalf("reply missing %q: %s", want, got) + } + } +} + +func TestSanitizeCommercialReplyTextWeakensEfficacyAndUnsupportedSpecs(t *testing.T) { + got := sanitizeCommercialReplyText("单独换个合适的枕头效果就很明显,能让肩颈腰都能放松;脊护效果好,头、肩、腰、臀、腿五个区域做了不同硬度的弹簧排布,弹簧线径更粗,还有2cm厚的乳胶。到店也是一样的,不会到店再变,部分活动款支持体验期。") + for _, banned := range []string{"效果就很明显", "肩颈腰都能放松", "脊护效果好", "五个区域做了不同硬度", "弹簧线径", "2cm厚", "不会到店再变", "支持体验期"} { + if strings.Contains(got, banned) { + t.Fatalf("reply still contains risky wording %q: %s", banned, got) + } + } + for _, want := range []string{"可能有帮助", "承托、释压体验", "更强调支撑承托", "承托差异设计", "舒适释压层", "门店当天活动", "订单条款"} { + if !strings.Contains(got, want) { + t.Fatalf("reply missing %q: %s", want, got) + } + } +} + +func TestEnsureLowBudgetPathAddsRealisticAlternative(t *testing.T) { + got := ensureLowBudgetPath("可以先看云感舒睡款,8000-13000元。", "我们预算最多三四千,超了就算了。") + for _, want := range []string{"三四千", "样品", "活动款", "暂不下单"} { + if !strings.Contains(got, want) { + t.Fatalf("reply missing %q: %s", want, got) + } + } +} + +func TestTailorDigitalStoreFallbackAnswersSpecificCustomerNeed(t *testing.T) { + fallback := "我是慕小眠,你的问题我已经记录;涉及最终价格、库存、退换货、售后争议或医疗效果时,我不能直接承诺,会安排门店顾问进一步确认。你也可以留下手机号或微信,方便顾问跟进。" + got := tailorDigitalStoreFallback(fallback, "我最近颈肩不舒服,想了解T10释压枕和床垫怎么搭配") + if strings.Contains(got, "你的问题我已经记录") || strings.Contains(got, "手机号或微信") { + t.Fatalf("expected tailored answer instead of generic fallback, got %q", got) + } + for _, want := range []string{"T10释压枕", "不一定要和床垫成套买", "预算有限"} { + if !strings.Contains(got, want) { + t.Fatalf("reply missing %q: %s", want, got) + } + } +} + +func TestSanitizeReplyAgainstCustomerContextRemovesUnsupportedBackConcern(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "那你到底现在能给我什么方案?", + }).Error; err != nil { + t.Fatalf("create customer message: %v", err) + } + got := sanitizeReplyAgainstCustomerContext("腰疼的话,这款适合您这样需要针对性支撑的情况。", conversation.ID) + if strings.Contains(got, "腰疼") || strings.Contains(got, "您这样") { + t.Fatalf("expected unsupported back concern to be removed, got %q", got) + } + if !strings.Contains(got, "想要更强承托") { + t.Fatalf("expected neutral support wording, got %q", got) + } +} + +func TestSanitizeReplyAgainstCustomerContextKeepsSupportedBackConcern(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我腰疼,想看护脊床垫。", + }).Error; err != nil { + t.Fatalf("create customer message: %v", err) + } + input := "腰背不适的话,这款可以重点试躺。" + if got := sanitizeReplyAgainstCustomerContext(input, conversation.ID); got != input { + t.Fatalf("expected supported back concern to stay unchanged, got %q", got) + } +} + +func TestSanitizeReplyAgainstKnownLeadInfoAvoidsAskingPhoneAgain(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我姓林,手机 13800138000,周六下午三点去徐汇店。", + }).Error; err != nil { + t.Fatalf("create customer message: %v", err) + } + got := sanitizeReplyAgainstKnownLeadInfo("麻烦您再留一下您的姓名和手机号,方便顾问提前帮您安排体验和礼包~", conversation.ID) + if strings.Contains(got, "再留") || strings.Contains(got, "姓名和手机号") { + t.Fatalf("expected duplicate phone ask to be removed, got %q", got) + } + if !strings.Contains(got, "转给门店顾问") { + t.Fatalf("expected handoff confirmation, got %q", got) + } +} + +func TestSanitizeStoreMentionAgainstCustomerContextRemovesUnmentionedArea(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "家里老人腰不舒服,有没有偏硬一点的?", + }).Error; err != nil { + t.Fatalf("create customer message: %v", err) + } + got := sanitizeStoreMentionAgainstCustomerContext("欢迎周末带老人来徐汇门店试躺。", conversation.ID) + if strings.Contains(got, "徐汇") { + t.Fatalf("expected unmentioned store area to be removed, got %q", got) + } + if !strings.Contains(got, "门店") { + t.Fatalf("expected generic store mention, got %q", got) + } +} + +func TestSanitizeStoreMentionAgainstCustomerContextKeepsMentionedArea(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我周六想去徐汇店试躺。", + }).Error; err != nil { + t.Fatalf("create customer message: %v", err) + } + input := "欢迎周末来徐汇门店试躺。" + if got := sanitizeStoreMentionAgainstCustomerContext(input, conversation.ID); got != input { + t.Fatalf("expected mentioned store area to stay, got %q", got) + } +} + +func TestEnsureConcretePlanWhenAskedAddsRetailPlan(t *testing.T) { + got := ensureConcretePlanWhenAsked("您是给自己用还是给长辈用?", "那你到底现在能给我什么方案?") + for _, want := range []string{"云感舒睡款", "脊护支撑款", "8000-13000", "12000-18000"} { + if !strings.Contains(got, want) { + t.Fatalf("expected concrete plan to include %q, got %q", want, got) + } + } +} + +func TestEnsureConcretePlanWhenAskedKeepsExistingPlan(t *testing.T) { + input := "可以先看云感舒睡款,偏柔和包裹。" + if got := ensureConcretePlanWhenAsked(input, "那你到底现在能给我什么方案?"); got != input { + t.Fatalf("expected existing plan unchanged, got %q", got) + } +} + +func TestSanitizeChitchatReplyAvoidsInventingPainPoint(t *testing.T) { + got := sanitizeChitchatReply("哈哈,写诗我可不太擅长。您最近睡得好吗?有没有腰酸背累或者床垫不舒服的情况?跟我说说,我帮您分析分析。", "你会写诗吗?") + if strings.Contains(got, "腰酸背累") || strings.Contains(got, "不太擅长") { + t.Fatalf("expected chitchat reply to avoid invented pain point, got %q", got) + } + if !strings.Contains(got, "可以简单来一句") || !strings.Contains(got, "随便看看") { + t.Fatalf("expected warmer chitchat redirect, got %q", got) + } +} + func setupReplyCommitTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name()) diff --git a/internal/ai/runtime/reply_service_test.go b/internal/ai/runtime/reply_service_test.go index 7ec35c45..11a60ca4 100644 --- a/internal/ai/runtime/reply_service_test.go +++ b/internal/ai/runtime/reply_service_test.go @@ -1,6 +1,7 @@ package runtime import ( + "strings" "testing" "time" @@ -68,6 +69,130 @@ func TestResolveReplyTimeout(t *testing.T) { } } +func TestBuildAIReplyFailureFallbackUsesCommercialGuardrails(t *testing.T) { + tests := []struct { + name string + input string + wantAll []string + }{ + { + name: "medical claim", + input: "能不能保证治好腰疼?", + wantAll: []string{"不能", "治疗", "医生", "试躺", "顾问"}, + }, + { + name: "final price and return promise", + input: "这款最低多少钱?不合适能不能保证退?", + wantAll: []string{"价格", "退换货", "不能", "顾问", "确认"}, + }, + { + name: "inventory", + input: "这款今天有没有现货?", + wantAll: []string{"库存", "现货", "不能", "顾问", "确认"}, + }, + { + name: "after sales", + input: "我之前买的床垫有异响怎么办?", + wantAll: []string{"售后", "异响", "人工", "检查", "顾问"}, + }, + { + name: "off topic", + input: "你会写诗吗?", + wantAll: []string{"可以", "睡眠", "床垫", "产品"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildAIReplyFailureFallback(tt.input, "默认兜底") + if strings.Contains(got, "响应有点慢") { + t.Fatalf("fallback should not expose slow-response wording:\n%s", got) + } + for _, want := range tt.wantAll { + if !strings.Contains(got, want) { + t.Fatalf("fallback missing %q:\n%s", want, got) + } + } + }) + } +} + +func TestBuildAIReplyFailureFallbackAcknowledgesPhoneForHumanHandoff(t *testing.T) { + got := buildAIReplyFailureFallback("订单找不到,电话是 13900001111,你让人工联系我。", "默认兜底") + for _, want := range []string{"联系方式", "已经记录", "人工", "顾问"} { + if !strings.Contains(got, want) { + t.Fatalf("fallback missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "留下手机号") || strings.Contains(got, "响应有点慢") { + t.Fatalf("fallback should not ask for phone again or expose slow-response wording:\n%s", got) + } +} + +func TestHandoffHoldingReplyAcknowledgesAfterSalesDetails(t *testing.T) { + got := buildHandoffHoldingReply("手机号是 13800138000,购买时间是2026年6月15日,能不能退赔?", 0) + for _, want := range []string{"联系方式", "退换/赔付", "售后", "订单条款", "检测结果"} { + if !strings.Contains(got, want) { + t.Fatalf("holding reply missing %q:\n%s", want, got) + } + } + for _, banned := range []string{"会一定退", "会一定赔", "马上上门", "今天联系"} { + if strings.Contains(got, banned) { + t.Fatalf("holding reply contains risky wording %q:\n%s", banned, got) + } + } +} + +func TestShouldSendHandoffHoldingReplyForUnassignedAfterSalesConversation(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + now := time.Now() + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Updates(map[string]any{ + "status": enums.IMConversationStatusPending, + "handoff_at": now, + "current_assignee_id": int64(0), + }).Error; err != nil { + t.Fatalf("update conversation: %v", err) + } + conversation.Status = enums.IMConversationStatusPending + conversation.HandoffAt = &now + + message := newCustomerMessageFixture("我已经给过电话了,没人处理我就投诉,能不能退赔?") + if !newAIReplyService().shouldSendHandoffHoldingReply(*conversation, message, *aiAgent) { + t.Fatalf("expected after-sales message in pending handoff conversation to get AI holding reply") + } + + conversation.CurrentAssigneeID = 99 + if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Update("current_assignee_id", int64(99)).Error; err != nil { + t.Fatalf("assign conversation: %v", err) + } + if !newAIReplyService().shouldSendHandoffHoldingReply(*conversation, message, *aiAgent) { + t.Fatalf("expected assigned but unreplied human conversation to keep AI holding reply") + } + if err := db.Create(&models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeAgent, + MessageType: enums.IMMessageTypeText, + Content: "您好,我是人工客服,正在查看。", + AuditFields: models.AuditFields{ + CreatedAt: now.Add(time.Second), + UpdatedAt: now.Add(time.Second), + }, + }).Error; err != nil { + t.Fatalf("create agent message: %v", err) + } + if newAIReplyService().shouldSendHandoffHoldingReply(*conversation, message, *aiAgent) { + t.Fatalf("expected AI to stay silent after human agent replied") + } +} + +func TestBuildAIReplyFailureFallbackUsesConfiguredDefault(t *testing.T) { + got := buildAIReplyFailureFallback("我想了解一下", "请留下联系方式,顾问稍后跟进。") + if got != "请留下联系方式,顾问稍后跟进。" { + t.Fatalf("unexpected fallback: %q", got) + } +} + func TestResolveInterruptPrompt(t *testing.T) { summary := &applicationruntime.Summary{ Interrupts: []applicationruntime.InterruptContextSummary{ diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 19686fd9..8794478d 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -10,7 +10,10 @@ import ( "agent-desk/internal/models" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/tracex" + "agent-desk/internal/repositories" svc "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" ) func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration { @@ -40,6 +43,12 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes "timeout_ms", timeout.Milliseconds(), "elapsed_ms", time.Since(startedAt).Milliseconds(), "error", err) + if fallbackErr := s.sendFailureFallback(conversation, message, *aiAgent); fallbackErr != nil { + slog.Error("failed to send ai failure fallback", + "requestId", message.RequestID, + "message_id", message.ID, + "error", fallbackErr) + } } }() } @@ -56,6 +65,9 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C return err } if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) { + if s.shouldSendHandoffHoldingReply(conversation, message, aiAgent) { + return s.sendHandoffHoldingReply(conversation, message, aiAgent) + } return nil } if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil { @@ -97,3 +109,154 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte } return nil } + +func (s *aiReplyService) sendFailureFallback(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) error { + if hasReplyAfterMessage(conversation.ID, message.ID) { + return nil + } + replyText := buildAIReplyFailureFallback(message.Content, aiAgent.FallbackMessage) + _, err := s.commit.SendAIReply(replyCommitInput{ + Conversation: conversation, + Message: message, + AIAgent: aiAgent, + ReplyText: replyText, + ClientPrefix: "ai_fallback", + }) + return err +} + +func (s *aiReplyService) shouldSendHandoffHoldingReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool { + if message.SenderType != enums.IMSenderTypeCustomer || strings.TrimSpace(message.Content) == "" { + return false + } + if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly { + return false + } + current := repositories.ConversationRepository.Get(sqls.DB(), conversation.ID) + if current == nil { + current = &conversation + } + if current.Status == enums.IMConversationStatusClosed { + return false + } + if current.CurrentAssigneeID > 0 && conversationHasAgentReplyAfterHandoff(current.ID, current.HandoffAt) { + return false + } + if current.HandoffAt == nil && current.Status != enums.IMConversationStatusPending && current.Status != enums.IMConversationStatusActive { + return false + } + return containsAny(strings.ToLower(message.Content), + "人工", "真人", "客服", "售后", "投诉", "异响", "咯吱", "退", "赔", "上门", "联系", "电话", "手机号", "订单", "购买时间", "没人处理", "没人联系", + "提供什么", "补充什么", "还要", "材料", "型号", "位置", "视频", + "怎么跟进", "怎么处理", "接下来", "确认下", "确认一下", "流程", + ) +} + +func conversationHasAgentReplyAfterHandoff(conversationID int64, handoffAt *time.Time) bool { + if conversationID <= 0 || handoffAt == nil { + return false + } + item := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("sender_type = ?", enums.IMSenderTypeAgent). + Where("created_at >= ?", *handoffAt). + Asc("id")) + return item != nil +} + +func (s *aiReplyService) sendHandoffHoldingReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) error { + replyText := buildHandoffHoldingReply(message.Content, conversation.ID) + _, err := s.commit.SendAIReply(replyCommitInput{ + Conversation: conversation, + Message: message, + AIAgent: aiAgent, + ReplyText: replyText, + ClientPrefix: "ai_handoff_hold", + }) + return err +} + +func buildHandoffHoldingReply(customerContent string, conversationID int64) string { + content := strings.TrimSpace(customerContent) + hasPhone := normalizeFallbackPhone(strings.ToLower(content)) != "" || customerHistoryHasPhone(conversationID) + switch { + case containsAny(strings.ToLower(content), "退", "赔", "保证", "一定"): + prefix := "我已收到您关于退换/赔付的诉求" + if hasPhone { + prefix = "我已收到您补充的联系方式和退换/赔付诉求" + } + return prefix + ",会继续转给人工/售后顾问确认。这里不能先承诺一定退换或赔付,后续需要结合订单条款、产品型号和售后检测结果判断。" + case containsAny(strings.ToLower(content), "上门", "今天", "什么时候", "多久", "没人处理", "没人联系", "投诉"): + prefix := "我已收到您的催促和投诉诉求" + if hasPhone { + prefix = "我已收到您的联系方式、催促和投诉诉求" + } + return prefix + ",会继续转给人工/售后顾问处理。具体联系时间、上门方式和处理结论需要以售后排班及订单信息确认为准;您也可以继续补充订单号、型号、异响位置或视频情况。" + case containsAny(strings.ToLower(content), "怎么跟进", "怎么处理", "接下来", "确认下", "确认一下", "流程"): + return "接下来我会把您已补充的联系方式、购买时间和异响诉求继续转给人工/售后顾问;售后会结合订单、型号、异响位置和必要的视频或检测情况确认处理方式。这里不先承诺上门时间、退换或赔付结论,最终以售后确认结果为准。" + case containsAny(strings.ToLower(content), "提供什么", "补充什么", "还要", "材料"): + return "可以继续补充购买时间、订单号、产品型号、异响位置,以及是否方便提供一段翻身异响的视频;这些信息会帮助售后顾问更快判断检测方式。处理结论仍以订单信息和售后检测为准。" + case hasPhone: + return "我已收到您补充的联系方式和售后信息,会继续转给人工/售后顾问确认。异响原因、是否上门、退换或赔付,都需要结合订单、产品型号、床架/排骨架和检测结果判断,我不会在这里先替结果下结论。" + default: + return "我已收到您的补充信息,当前会继续转给人工/售后顾问确认。为了方便后续处理,可以补充手机号、购买时间、订单号、产品型号和异响位置;退换、赔付或上门时间需要以售后确认结果为准。" + } +} + +func hasReplyAfterMessage(conversationID int64, messageID int64) bool { + if conversationID <= 0 || messageID <= 0 { + return false + } + item := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("id > ?", messageID). + Where("sender_type <> ?", enums.IMSenderTypeCustomer). + Asc("id")) + return item != nil +} + +func buildAIReplyFailureFallback(customerContent string, configuredFallback string) string { + content := strings.ToLower(strings.TrimSpace(customerContent)) + hasPhone := normalizeFallbackPhone(content) != "" + switch { + case containsAny(content, "人工", "真人", "客服") && hasPhone: + return "收到,你留下的联系方式我已经记录。我会按人工/门店顾问跟进处理;涉及最终价格、库存、退换或售后结论,需要顾问结合订单和门店信息确认。" + case containsAny(content, "治好", "治疗", "腰疼", "腰痛", "医生", "疾病"): + return "我先给你一个稳妥答复:床垫不能替代医疗诊断或治疗,也不能保证治好腰疼;如果持续疼痛,建议先咨询医生。睡眠支撑方面可以到店试躺,我也可以安排门店顾问进一步确认适合的护脊款式。" + case containsAny(content, "最低", "便宜", "成交价", "保证退", "退货", "退款", "退换"): + return "关于最终价格、额外优惠和退换货政策,我不能给出未经门店确认的承诺;这些需要以门店顾问确认和购买合同为准。你可以留下手机号或微信,我会安排顾问跟进确认。" + case containsAny(content, "库存", "现货", "有货"): + return "库存和现货会实时变化,我不能直接承诺一定有货;建议留下联系方式或到店前让门店顾问确认规格和库存。" + case containsAny(content, "售后", "投诉", "异响", "质保", "不满意", "差评"): + if hasPhone { + return "你反馈的售后/投诉诉求和联系方式我已经记录,会转给人工顾问继续确认。异响、退换或赔付需要结合订单、产品型号和售后检查/检测结果判断,我不会在这里先替结果下结论。" + } + return "你反馈的售后/投诉诉求我已经记录,会转给人工顾问继续确认。异响、退换或赔付需要结合订单、产品型号和售后检查/检测结果判断;可以补充购买时间、型号、异响位置和联系方式。" + case containsAny(content, "写诗", "闲聊", "聊天"): + return "可以呀,短短来一句:好睡像云落在肩上,醒来把疲惫放下。不过我更擅长慕斯寝具、床垫、电动床等产品和预约咨询,你想随便看看还是有睡眠困扰?" + } + if fallback := strings.TrimSpace(configuredFallback); fallback != "" { + return fallback + } + return "你的问题我已经记录。我会先按门店知识继续为你确认;如果涉及价格、库存、售后或最终承诺,建议留下联系方式让顾问接着跟进。" +} + +func containsAny(value string, keywords ...string) bool { + for _, keyword := range keywords { + if strings.Contains(value, strings.ToLower(keyword)) { + return true + } + } + return false +} + +func normalizeFallbackPhone(value string) string { + for _, field := range strings.FieldsFunc(value, func(r rune) bool { + return r < '0' || r > '9' + }) { + if len(field) == 11 && strings.HasPrefix(field, "1") { + return field + } + } + return "" +} diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index 3e2cdc6a..2f9e8d37 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -433,6 +433,11 @@ func understandConversationMessage(rawMessage string) workflowConversationUnders } lower := strings.ToLower(message) switch { + case isIdentityWorkflowQuestion(lower): + ret.MessageIntent = "identity" + ret.AnswerScope = "direct_reply" + ret.Confidence = 0.94 + ret.Reason = "matched identity question" case isGreetingMessage(lower): ret.MessageIntent = "greeting" ret.AnswerScope = "direct_reply" @@ -466,7 +471,17 @@ func understandConversationMessage(rawMessage string) workflowConversationUnders ret.Confidence = 0.9 ret.RiskSignals = append(ret.RiskSignals, "ticket_expected") ret.Reason = "matched ticket phrase" - case containsAnyWorkflowText(lower, "确认", "可以", "好的", "好", "是的", "取消"): + case isKnowledgeSeekingWorkflowQuestion(lower): + ret.MessageIntent = "business_question" + ret.AnswerScope = "needs_knowledge" + ret.Confidence = 0.86 + ret.Reason = "matched knowledge seeking question" + case isRetailNeedWorkflowMessage(lower): + ret.MessageIntent = "business_question" + ret.AnswerScope = "needs_knowledge" + ret.Confidence = 0.84 + ret.Reason = "matched retail consultation need" + case isConfirmationWorkflowMessage(lower): ret.MessageIntent = "confirmation" ret.AnswerScope = "direct_reply" ret.Confidence = 0.8 @@ -497,6 +512,8 @@ func decideWorkflowReplyPolicy(aiAgent models.AIAgent, input workflowReplyPolicy } } switch { + case intent == "identity": + return workflowReplyPolicyDecision{Action: "direct_reply", ReplyText: workflowIdentityReply(aiAgent), Reason: "identity question can be answered directly", FinalReplySource: "direct_reply"} case intent == "greeting": return workflowReplyPolicyDecision{Action: "direct_reply", ReplyText: "您好,请问有什么可以帮您?", Reason: "greeting can be answered directly", FinalReplySource: "direct_reply"} case intent == "thanks": @@ -510,7 +527,7 @@ func decideWorkflowReplyPolicy(aiAgent models.AIAgent, input workflowReplyPolicy case intent == "ticket_request" || scope == "needs_ticket": return workflowReplyPolicyDecision{Action: "prepare_ticket", Reason: "user requested ticket handling", RequiresFlow: true, TargetFlow: "prepare_ticket", FinalReplySource: "ticket_result"} case intent == "ambiguous_question" || scope == "needs_clarification": - return workflowReplyPolicyDecision{Action: "clarify", ReplyText: "请补充具体的产品、场景、报错信息或你希望处理的结果,我再继续帮你确认。", Reason: "message needs clarification", FinalReplySource: "clarification"} + return workflowReplyPolicyDecision{Action: "clarify", ReplyText: "我在的。你可以直接说睡眠困扰、预算、给谁用,或者想看的床垫/电动床款式,我就按你的情况帮你推荐。", Reason: "message needs clarification", FinalReplySource: "clarification"} case scope == "needs_knowledge": return workflowReplyPolicyDecision{Action: "retrieve_knowledge", Reason: "business question should be answered with knowledge evidence", RequiresFlow: true, TargetFlow: "knowledge", FinalReplySource: "knowledge_answer"} default: @@ -518,6 +535,14 @@ func decideWorkflowReplyPolicy(aiAgent models.AIAgent, input workflowReplyPolicy } } +func workflowIdentityReply(aiAgent models.AIAgent) string { + name := strings.TrimSpace(aiAgent.Name) + if name == "" { + name = "慕小眠" + } + return "我是" + name + ",慕斯寝具的在线睡眠顾问,可以帮你挑床垫、电动床、预约试躺,也能把价格、库存、售后这类需要确认的事转给门店顾问。" +} + func normalizeWorkflowUserMessage(value string) string { value = strings.TrimSpace(value) if value == "" { @@ -534,11 +559,57 @@ func isGreetingMessage(value string) bool { return containsAnyWorkflowText(trimmed, "你好", "您好", "在吗", "在不在") || trimmed == "hello" || trimmed == "hi" } +func isIdentityWorkflowQuestion(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + if trimmed == "" { + return false + } + return containsAnyWorkflowText(trimmed, + "你是谁", "你是干嘛", "你是做什么", "你叫什么", "你叫啥", "你是什么", + "机器人吗", "真人吗", "你听得懂吗", "你能干嘛", "你会什么", + ) +} + func isAmbiguousWorkflowQuestion(value string) bool { trimmed := strings.Trim(value, " \r\n。.!!??~~") return containsAnyWorkflowText(trimmed, "怎么弄", "怎么办", "怎么处理", "帮我看看", "有问题") || len([]rune(trimmed)) <= 3 } +func isKnowledgeSeekingWorkflowQuestion(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + if trimmed == "" { + return false + } + return containsAnyWorkflowText(trimmed, + "?", "?", "吗", "呢", "么", "是不是", "是否", "能不能", "能否", "可不可以", "为什么", "怎么", "如何", "哪", "哪个", "哪种", "什么", + "推荐", "适合", "价格", "多少钱", "多少", "预算", "型号", "产品", "功能", "区别", "对比", "活动", "优惠", + ) +} + +func isRetailNeedWorkflowMessage(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + if trimmed == "" { + return false + } + return containsAnyWorkflowText(trimmed, + "腰疼", "腰痛", "腰酸", "腰不好", "背疼", "背痛", "护脊", "脊椎", "颈椎", + "睡觉疼", "睡觉累", "睡不好", "睡不着", "失眠", "翻身", "起夜", "打鼾", "怕热", + "床垫", "床", "电动床", "枕头", "寝具", "软床", "硬床", "软一点", "硬一点", "太软", "太硬", + "老人", "爸", "妈", "父母", "孩子", "儿童", "孕妇", "夫妻", "主卧", "次卧", + "侧睡", "仰睡", "趴睡", "试躺", "到店", "预约", "徐汇", "门店", "电话", "微信", + "现货", "有货", "库存", "退货", "退款", "退换", "质保", "异响", "预算", + ) +} + +func isConfirmationWorkflowMessage(value string) bool { + trimmed := strings.Trim(value, " \r\n。.!!??~~") + switch trimmed { + case "确认", "可以", "可以的", "好的", "好", "是的", "嗯", "嗯嗯", "行", "取消": + return true + } + return (strings.HasPrefix(trimmed, "确认") || strings.HasPrefix(trimmed, "取消")) && len([]rune(trimmed)) <= 8 +} + func containsAnyWorkflowText(value string, needles ...string) bool { for _, needle := range needles { if strings.Contains(value, needle) { @@ -742,6 +813,9 @@ func (e *Executor) executeAnswerabilityGate(state *runState, node dsl.Node) erro if hasItems(items) { answerability = "answerable" reason = "retrieved knowledge items are available" + } else if strings.TrimSpace(services.DigitalStoreProfileService.BuildRuntimeInstruction()) != "" { + answerability = "answerable" + reason = "digital store runtime context is available" } state.setNodeVars(node.ID, map[string]any{ "answerability": answerability, @@ -761,10 +835,14 @@ func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node ds } knowledgeItems := toString(state.resolveInput(node, "knowledgeItems")) systemPrompt := strings.TrimSpace(state.input.AIAgent.SystemPrompt) + runtimeInstruction := services.DigitalStoreProfileService.BuildRuntimeInstruction() + if runtimeInstruction != "" { + systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + runtimeInstruction) + } if prompt := strings.TrimSpace(readStringConfig(node.Config, "prompt")); prompt != "" { systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + prompt) } - if _, declaresKnowledge := node.Inputs["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) { + if _, declaresKnowledge := node.Inputs["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) && runtimeInstruction == "" { state.setNodeVars(node.ID, map[string]any{"replyText": workflowKnowledgeFallbackReply(state.input.AIAgent)}) return nil } diff --git a/internal/ai/runtime/workflow/executor_test.go b/internal/ai/runtime/workflow/executor_test.go index b5729e28..6669e0ba 100644 --- a/internal/ai/runtime/workflow/executor_test.go +++ b/internal/ai/runtime/workflow/executor_test.go @@ -275,7 +275,7 @@ func TestExecutorPolicyFirstWorkflowRoutesBusinessQuestionToKnowledge(t *testing result, err := NewExecutor().Execute(context.Background(), Input{ Definition: policyFirstWorkflowDefinition(), UserMessage: models.Message{ - Content: "你们价格是多少?", + Content: "老人腰不好,床垫是不是越硬越好?预算一万五推荐哪种?", }, AIAgent: models.AIAgent{ KnowledgeIDs: "1", @@ -288,6 +288,63 @@ func TestExecutorPolicyFirstWorkflowRoutesBusinessQuestionToKnowledge(t *testing assertPath(t, result.NodePath, []string{"start_1", "understanding_1", "policy_1", "policy_route_1", "retrieve_end"}) } +func TestUnderstandConversationMessageDoesNotTreatRiskQuestionAsConfirmation(t *testing.T) { + got := understandConversationMessage("能不能保证治好腰疼?") + if got.MessageIntent != "business_question" || got.AnswerScope != "needs_knowledge" { + t.Fatalf("expected risk question to need knowledge, got %#v", got) + } +} + +func TestUnderstandConversationMessageRoutesShortRetailNeedToKnowledge(t *testing.T) { + tests := []string{ + "我腰疼", + "太软", + "老人起夜", + } + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + got := understandConversationMessage(tt) + if got.MessageIntent != "business_question" || got.AnswerScope != "needs_knowledge" { + t.Fatalf("expected retail need to need knowledge, got %#v", got) + } + }) + } +} + +func TestWorkflowClarificationReplyUsesRetailLanguage(t *testing.T) { + got := decideWorkflowReplyPolicy(models.AIAgent{}, workflowReplyPolicyInput{ + MessageIntent: "ambiguous_question", + AnswerScope: "needs_clarification", + }) + if !strings.Contains(got.ReplyText, "睡眠困扰") || strings.Contains(got.ReplyText, "报错信息") { + t.Fatalf("expected retail clarification reply, got %q", got.ReplyText) + } +} + +func TestUnderstandConversationMessageRoutesIdentityQuestionToDirectReply(t *testing.T) { + got := understandConversationMessage("你是谁") + if got.MessageIntent != "identity" || got.AnswerScope != "direct_reply" { + t.Fatalf("expected identity direct reply, got %#v", got) + } +} + +func TestWorkflowIdentityReplyAnswersRole(t *testing.T) { + got := decideWorkflowReplyPolicy(models.AIAgent{Name: "慕小眠"}, workflowReplyPolicyInput{ + MessageIntent: "identity", + AnswerScope: "direct_reply", + }) + if !strings.Contains(got.ReplyText, "慕小眠") || !strings.Contains(got.ReplyText, "慕斯寝具") || !strings.Contains(got.ReplyText, "睡眠顾问") { + t.Fatalf("expected concrete identity reply, got %q", got.ReplyText) + } +} + +func TestUnderstandConversationMessageStillRecognizesShortConfirmation(t *testing.T) { + got := understandConversationMessage("好的") + if got.MessageIntent != "confirmation" || got.AnswerScope != "direct_reply" { + t.Fatalf("expected confirmation, got %#v", got) + } +} + func TestExecutorLLMReplyUsesAgentFallbackWhenDeclaredKnowledgeIsEmpty(t *testing.T) { result, err := NewExecutor().Execute(context.Background(), Input{ Definition: emptyKnowledgeReplyDefinition(), @@ -313,6 +370,41 @@ func TestExecutorLLMReplyUsesAgentFallbackWhenDeclaredKnowledgeIsEmpty(t *testin assertPath(t, result.NodePath, []string{"start_1", "reply_1", "send_1", "end_1"}) } +func TestExecutorAnswerabilityAllowsDigitalStoreRuntimeContext(t *testing.T) { + db := setupWorkflowExecutorDigitalStoreRuntimeDB(t) + rawConfig := `{"brandName":"慕斯寝具","industry":"家居寝具","storeName":"徐汇体验店","aiManagerName":"慕小眠","initialized":true}` + if err := db.Create(&models.SystemConfig{ + ConfigKey: "digital_store.profile", + ConfigValue: rawConfig, + GroupCode: "digital_store", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create digital store config: %v", err) + } + state := &runState{ + vars: map[string]map[string]any{ + "retrieve_1": {"items": []map[string]any{}}, + }, + } + node := dsl.Node{ + ID: "answerability_1", + Type: workflowregistry.NodeTypeAnswerabilityGate, + Inputs: map[string]dsl.VariableSelector{ + "knowledgeItems": {NodeID: "retrieve_1", Field: "items"}, + }, + } + + if err := NewExecutor().executeAnswerabilityGate(state, node); err != nil { + t.Fatalf("executeAnswerabilityGate() error = %v", err) + } + if got := state.vars["answerability_1"]["answerability"]; got != "answerable" { + t.Fatalf("expected answerable with digital store runtime context, got %#v", got) + } + if got := state.vars["answerability_1"]["reason"]; got != "digital store runtime context is available" { + t.Fatalf("unexpected reason: %#v", got) + } +} + func TestExecutorHumanConfirmInterruptsWithCheckpoint(t *testing.T) { result, err := NewExecutor().Execute(context.Background(), Input{ Definition: humanConfirmWorkflowDefinition(), @@ -763,6 +855,26 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB { return db } +func setupWorkflowExecutorDigitalStoreRuntimeDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.SystemConfig{}, &models.Product{}, &models.Promotion{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + func createWorkflowExecutorHandoffAIAgent(t *testing.T, db *gorm.DB, teamIDs string) models.AIAgent { t.Helper() item := models.AIAgent{ diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 138092d3..9ad9f7e8 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -47,6 +47,15 @@ func registerDashboardDashboardRoutes(group *gin.RouterGroup) { group.GET("/overview", dashboard.DashboardGetOverview) } +func registerDashboardBusinessReportRoutes(group *gin.RouterGroup) { + group.GET("/ab-tests", dashboard.DashboardGetABTestReport) + group.GET("/ai-quality", dashboard.DashboardGetAIQualityReport) + group.GET("/daily", dashboard.DashboardGetDailyBusinessReport) + group.GET("/sales-funnel", dashboard.DashboardGetSalesFunnelReport) + group.GET("/trends", dashboard.DashboardGetBusinessTrendReport) + group.POST("/daily/send", dashboard.DashboardPostDailyBusinessReportSend) +} + func registerDashboardUserRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.UserGetBy) group.POST("/assign_role", dashboard.UserPostAssign_role) @@ -79,6 +88,71 @@ func registerDashboardCustomerRoutes(group *gin.RouterGroup) { group.POST("/update_status", dashboard.CustomerPostUpdate_status) } +func registerDashboardSalesLeadRoutes(group *gin.RouterGroup) { + group.GET("/export", dashboard.SalesLeadGetExport) + group.GET("/:id", dashboard.SalesLeadGetBy) + group.POST("/assign", dashboard.SalesLeadPostAssign) + group.POST("/claim-unassigned", dashboard.SalesLeadPostClaimUnassigned) + group.POST("/crm/sync", dashboard.SalesLeadPostCrmSync) + group.POST("/appointment/reminder/send", dashboard.SalesLeadPostAppointmentReminderSend) + group.POST("/appointment/summary", dashboard.SalesLeadPostAppointmentSummary) + group.POST("/follow-up/create", dashboard.SalesLeadPostFollowUpCreate) + group.POST("/follow-up/reminder/send", dashboard.SalesLeadPostFollowUpReminderSend) + group.POST("/follow-up/reminder/summary", dashboard.SalesLeadPostFollowUpReminderSummary) + group.POST("/list", dashboard.SalesLeadPostList) + group.POST("/update", dashboard.SalesLeadPostUpdate) + group.POST("/update-status", dashboard.SalesLeadPostUpdateStatus) +} + +func registerDashboardProductRoutes(group *gin.RouterGroup) { + group.GET("/:id", dashboard.ProductGetBy) + group.POST("/create", dashboard.ProductPostCreate) + group.POST("/delete", dashboard.ProductPostDelete) + group.POST("/import", dashboard.ProductPostImport) + group.POST("/list", dashboard.ProductPostList) + group.POST("/reindex", dashboard.ProductPostReindex) + group.POST("/seed_muse", dashboard.ProductPostSeed_muse) + group.POST("/update", dashboard.ProductPostUpdate) + group.POST("/update_status", dashboard.ProductPostUpdate_status) +} + +func registerDashboardPromotionRoutes(group *gin.RouterGroup) { + group.GET("/:id", dashboard.PromotionGetBy) + group.POST("/create", dashboard.PromotionPostCreate) + group.POST("/delete", dashboard.PromotionPostDelete) + group.POST("/import", dashboard.PromotionPostImport) + group.POST("/list", dashboard.PromotionPostList) + group.POST("/reindex", dashboard.PromotionPostReindex) + group.POST("/seed_muse", dashboard.PromotionPostSeed_muse) + group.POST("/update", dashboard.PromotionPostUpdate) + group.POST("/update_status", dashboard.PromotionPostUpdate_status) +} + +func registerDashboardDigitalStoreRoutes(group *gin.RouterGroup) { + group.GET("/delivery_report", dashboard.DigitalStoreGetDelivery_report) + group.GET("/delivery_records/latest", dashboard.DigitalStoreGetDelivery_record_latest) + group.GET("/knowledge_assistant", dashboard.DigitalStoreGetKnowledge_assistant) + group.GET("/maintenance_status", dashboard.DigitalStoreGetMaintenance_status) + group.GET("/setup_status", dashboard.DigitalStoreGetSetup_status) + group.GET("/template_effect", dashboard.DigitalStoreGetTemplate_effect) + group.GET("/templates/export", dashboard.DigitalStoreGetTemplate_export) + group.GET("/templates/preview", dashboard.DigitalStoreGetTemplate_preview) + group.GET("/templates", dashboard.DigitalStoreGetTemplates) + group.GET("/profile", dashboard.DigitalStoreGetProfile) + group.POST("/apply_imported_template", dashboard.DigitalStorePostApply_imported_template) + group.POST("/apply_template", dashboard.DigitalStorePostApply_template) + group.POST("/cleanup_demo_data", dashboard.DigitalStorePostCleanup_demo_data) + group.POST("/delivery_records/acceptance_result", dashboard.DigitalStorePostDelivery_record_acceptance_result) + group.POST("/delivery_records/create", dashboard.DigitalStorePostDelivery_record_create) + group.POST("/ensure_runtime", dashboard.DigitalStorePostEnsure_runtime) + group.POST("/profile", dashboard.DigitalStorePostProfile) + group.POST("/seed_muse", dashboard.DigitalStorePostSeed_muse) + group.POST("/sync_knowledge", dashboard.DigitalStorePostSync_knowledge) + group.POST("/templates/import_preview", dashboard.DigitalStorePostTemplate_import_preview) + group.POST("/test_webhook_notify", dashboard.DigitalStorePostTest_webhook_notify) + group.POST("/test_webhook_notify_scenarios", dashboard.DigitalStorePostTest_webhook_notify_scenarios) +} + func registerDashboardCustomerContactRoutes(group *gin.RouterGroup) { group.POST("/create", dashboard.CustomerContactPostCreate) group.POST("/delete", dashboard.CustomerContactPostDelete) @@ -127,12 +201,14 @@ func registerDashboardConversationRoutes(group *gin.RouterGroup) { group.POST("/close", dashboard.ConversationPostClose) group.Any("/conversations", dashboard.ConversationAnyConversations) group.POST("/dispatch", dashboard.ConversationPostDispatch) + group.POST("/follow_up_advice", dashboard.ConversationPostFollow_up_advice) group.POST("/link_customer", dashboard.ConversationPostLink_customer) group.Any("/list", dashboard.ConversationAnyList) group.Any("/message_list", dashboard.ConversationAnyMessage_list) group.POST("/read", dashboard.ConversationPostRead) group.POST("/recall_message", dashboard.ConversationPostRecall_message) group.POST("/remove_tag", dashboard.ConversationPostRemove_tag) + group.POST("/resume_ai", dashboard.ConversationPostResume_ai) group.POST("/send_message", dashboard.ConversationPostSend_message) group.POST("/transfer", dashboard.ConversationPostTransfer) group.POST("/upload_attachment", dashboard.ConversationPostUpload_attachment) @@ -295,6 +371,7 @@ func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) { group.POST("/import", dashboard.KnowledgeFAQPostImport) group.Any("/list", dashboard.KnowledgeFAQAnyList) group.POST("/update", dashboard.KnowledgeFAQPostUpdate) + group.POST("/update_status", dashboard.KnowledgeFAQPostUpdate_status) } func registerDashboardKnowledgeRetrieveRoutes(group *gin.RouterGroup) { @@ -306,6 +383,9 @@ func registerDashboardKnowledgeRetrieveRoutes(group *gin.RouterGroup) { func registerDashboardKnowledgeRetrieveLogRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.KnowledgeRetrieveLogGetBy) group.Any("/list", dashboard.KnowledgeRetrieveLogAnyList) + group.POST("/faq-draft/batch-create", dashboard.KnowledgeRetrieveLogPostFaq_draft_batch_create) + group.POST("/feedback/create", dashboard.KnowledgeRetrieveLogPostFeedback_create) + group.POST("/faq-draft/create", dashboard.KnowledgeRetrieveLogPostFaq_draft_create) } func registerDashboardSkillDefinitionRoutes(group *gin.RouterGroup) { diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index abcebba8..5c9ee1f9 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -171,9 +171,14 @@ func addRouter(app *gin.Engine) { dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware) registerDashboardDashboardRoutes(dashboardGroup.Group("/dashboard")) + registerDashboardBusinessReportRoutes(dashboardGroup.Group("/business-report")) registerDashboardUserRoutes(dashboardGroup.Group("/user")) registerDashboardCompanyRoutes(dashboardGroup.Group("/company")) registerDashboardCustomerRoutes(dashboardGroup.Group("/customer")) + registerDashboardSalesLeadRoutes(dashboardGroup.Group("/sales-lead")) + registerDashboardProductRoutes(dashboardGroup.Group("/product")) + registerDashboardPromotionRoutes(dashboardGroup.Group("/promotion")) + registerDashboardDigitalStoreRoutes(dashboardGroup.Group("/digital-store")) registerDashboardCustomerContactRoutes(dashboardGroup.Group("/customer-contact")) registerDashboardRoleRoutes(dashboardGroup.Group("/role")) registerDashboardPermissionRoutes(dashboardGroup.Group("/permission")) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 592e6b19..9ee791fb 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -196,7 +197,13 @@ func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { contentType string }{ {path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"}, - {path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"}, + } + if _, err := os.Stat("../../web/out/index.html"); err == nil { + tests = append(tests, struct { + path string + wantStatus int + contentType string + }{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"}) } for _, tt := range tests { diff --git a/internal/builders/knowledge_builder.go b/internal/builders/knowledge_builder.go index f5833fcf..f13b1eb0 100644 --- a/internal/builders/knowledge_builder.go +++ b/internal/builders/knowledge_builder.go @@ -179,6 +179,20 @@ func BuildKnowledgeRetrieveHitResponse(item *models.KnowledgeRetrieveHit) respon } } +func BuildKnowledgeFeedback(item *models.KnowledgeFeedback) response.KnowledgeFeedbackResponse { + return response.KnowledgeFeedbackResponse{ + ID: item.ID, + RetrieveLogID: item.RetrieveLogID, + FeedbackType: item.FeedbackType, + FeedbackTypeName: enums.GetKnowledgeFeedbackTypeLabel(enums.KnowledgeFeedbackType(item.FeedbackType)), + FeedbackReason: item.FeedbackReason, + UserID: item.UserID, + AgentID: item.AgentID, + Remark: item.Remark, + CreatedAt: item.CreatedAt, + } +} + func parseSimilarQuestions(raw string) []string { if raw == "" { return []string{} diff --git a/internal/builders/product_builder.go b/internal/builders/product_builder.go new file mode 100644 index 00000000..aa99d543 --- /dev/null +++ b/internal/builders/product_builder.go @@ -0,0 +1,44 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/utils" +) + +func BuildProduct(item *models.Product) *response.ProductResponse { + if item == nil { + return nil + } + return &response.ProductResponse{ + ID: item.ID, + Name: item.Name, + Category: item.Category, + PriceMin: item.PriceMin, + PriceMax: item.PriceMax, + SellingPoints: item.SellingPoints, + SuitablePeople: item.SuitablePeople, + UnsuitablePeople: item.UnsuitablePeople, + Scenarios: item.Scenarios, + Specs: item.Specs, + IndustryAttributes: item.IndustryAttributes, + ImageURL: item.ImageURL, + Priority: item.Priority, + KnowledgeBaseID: item.KnowledgeBaseID, + KnowledgeFAQID: item.KnowledgeFAQID, + Status: item.Status, + Remark: item.Remark, + CreatedAt: utils.FormatTime(item.CreatedAt), + UpdatedAt: utils.FormatTime(item.UpdatedAt), + } +} + +func BuildProductList(list []models.Product) []response.ProductResponse { + results := make([]response.ProductResponse, 0, len(list)) + for i := range list { + if item := BuildProduct(&list[i]); item != nil { + results = append(results, *item) + } + } + return results +} diff --git a/internal/builders/promotion_builder.go b/internal/builders/promotion_builder.go new file mode 100644 index 00000000..7f4934be --- /dev/null +++ b/internal/builders/promotion_builder.go @@ -0,0 +1,43 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/utils" +) + +func BuildPromotion(item *models.Promotion) *response.PromotionResponse { + if item == nil { + return nil + } + return &response.PromotionResponse{ + ID: item.ID, + Name: item.Name, + PromotionType: item.PromotionType, + Description: item.Description, + ApplicableProducts: item.ApplicableProducts, + StartAt: utils.FormatTimePtr(item.StartAt), + EndAt: utils.FormatTimePtr(item.EndAt), + DiscountRule: item.DiscountRule, + StoreBenefit: item.StoreBenefit, + AppointmentBenefit: item.AppointmentBenefit, + ScriptSuggestion: item.ScriptSuggestion, + Priority: item.Priority, + KnowledgeBaseID: item.KnowledgeBaseID, + KnowledgeFAQID: item.KnowledgeFAQID, + Status: item.Status, + Remark: item.Remark, + CreatedAt: utils.FormatTime(item.CreatedAt), + UpdatedAt: utils.FormatTime(item.UpdatedAt), + } +} + +func BuildPromotionList(list []models.Promotion) []response.PromotionResponse { + results := make([]response.PromotionResponse, 0, len(list)) + for i := range list { + if item := BuildPromotion(&list[i]); item != nil { + results = append(results, *item) + } + } + return results +} diff --git a/internal/builders/sales_lead_builder.go b/internal/builders/sales_lead_builder.go new file mode 100644 index 00000000..326f7fc7 --- /dev/null +++ b/internal/builders/sales_lead_builder.go @@ -0,0 +1,243 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + "agent-desk/internal/services" + "fmt" + "strings" + "time" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +const salesLeadMessageSummaryLimit = 120 + +var salesLeadBuilderDB = func() *gorm.DB { + return sqls.DB() +} + +func BuildSalesLead(item *models.SalesLead) *response.SalesLeadResponse { + if item == nil { + return nil + } + autoTagDetails := buildSalesLeadAutoTagDetails(item) + ret := &response.SalesLeadResponse{ + ID: item.ID, + CustomerID: item.CustomerID, + ConversationID: item.ConversationID, + CustomerName: item.CustomerName, + Phone: item.Phone, + WeChat: item.WeChat, + City: item.City, + AddressHint: item.AddressHint, + BudgetMin: item.BudgetMin, + BudgetMax: item.BudgetMax, + InterestedProducts: item.InterestedProducts, + DemandSummary: item.DemandSummary, + IntentLevel: item.IntentLevel, + BuyingStage: item.BuyingStage, + AppointmentAt: utils.FormatTimePtr(item.AppointmentAt), + AppointmentTimeText: item.AppointmentTimeText, + AppointmentStore: item.AppointmentStore, + AppointmentPeople: item.AppointmentPeople, + AppointmentRemark: item.AppointmentRemark, + SourceChannel: item.SourceChannel, + OwnerUserID: item.OwnerUserID, + Status: item.Status, + NextFollowUpAt: utils.FormatTimePtr(item.NextFollowUpAt), + LastMessageID: item.LastMessageID, + LastMessageSummary: buildSalesLeadConversationSummary(item), + LastCustomerMessage: buildSalesLeadLastCustomerMessage(item), + MergeKey: item.MergeKey, + MergeReason: item.MergeReason, + MergedAt: utils.FormatTimePtr(item.MergedAt), + Remark: item.Remark, + AutoTags: salesLeadAutoTagLabels(autoTagDetails), + AutoTagDetails: autoTagDetails, + CreatedAt: utils.FormatTime(item.CreatedAt), + UpdatedAt: utils.FormatTime(item.UpdatedAt), + } + if item.CustomerID > 0 { + ret.Customer = BuildCustomer(services.CustomerService.Get(item.CustomerID)) + } + if item.OwnerUserID > 0 { + owner := services.UserService.Get(item.OwnerUserID) + if owner != nil { + ret.OwnerUserName = owner.Username + } + } + return ret +} + +func buildSalesLeadConversationSummary(item *models.SalesLead) string { + if item == nil || item.ConversationID <= 0 { + return "" + } + db := salesLeadBuilderDB() + if db == nil { + return "" + } + conversation := repositories.ConversationRepository.Get(db, item.ConversationID) + if conversation == nil { + return "" + } + return limitSalesLeadSummary(conversation.LastMessageSummary, salesLeadMessageSummaryLimit) +} + +func buildSalesLeadLastCustomerMessage(item *models.SalesLead) string { + if item == nil || item.LastMessageID <= 0 { + return "" + } + db := salesLeadBuilderDB() + if db == nil { + return "" + } + message := repositories.MessageRepository.Get(db, item.LastMessageID) + if message == nil || message.SenderType != enums.IMSenderTypeCustomer { + return "" + } + return limitSalesLeadSummary(message.Content, salesLeadMessageSummaryLimit) +} + +func limitSalesLeadSummary(value string, max int) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + if max <= 0 || value == "" { + return "" + } + runes := []rune(value) + if len(runes) <= max { + return value + } + return string(runes[:max]) + "..." +} + +func buildSalesLeadAutoTags(item *models.SalesLead) []string { + return salesLeadAutoTagLabels(buildSalesLeadAutoTagDetails(item)) +} + +func salesLeadAutoTagLabels(details []response.SalesLeadAutoTag) []string { + labels := make([]string, 0, len(details)) + for _, detail := range details { + labels = append(labels, detail.Label) + } + return labels +} + +func buildSalesLeadAutoTagDetails(item *models.SalesLead) []response.SalesLeadAutoTag { + if item == nil { + return nil + } + tags := make([]response.SalesLeadAutoTag, 0, 8) + add := func(label string, level string, reason string, actionLabel string, actionURL string) { + label = strings.TrimSpace(label) + if label == "" { + return + } + for _, existing := range tags { + if existing.Label == label { + return + } + } + tags = append(tags, response.SalesLeadAutoTag{ + Label: label, + Level: level, + Reason: reason, + ActionLabel: actionLabel, + ActionURL: actionURL, + }) + } + detailURL := "/dashboard/sales-leads" + if item.ID > 0 { + detailURL = "/dashboard/sales-leads?leadId=" + fmt.Sprint(item.ID) + } + if item.Status == enums.SalesLeadStatusConverted { + add("已成交", "success", "线索状态已标记为成交。", "沉淀成交话术", detailURL) + } + if item.Status == enums.SalesLeadStatusVisited { + add("已到店", "success", "客户已到店或已完成到店标记。", "补充到店结果", detailURL) + } + if item.IntentLevel == enums.SalesLeadIntentHigh { + add("高意向", "hot", "AI 或顾问判断客户购买意向较强。", "优先跟进", detailURL) + } + if item.BuyingStage == enums.SalesLeadStageReadyToBuy { + add("准成交", "hot", "客户已进入准成交阶段。", "确认报价与下单障碍", detailURL) + } + if item.BuyingStage == enums.SalesLeadStageAppointment || item.AppointmentAt != nil || item.AppointmentTimeText != "" || item.AppointmentStore != "" { + add("已预约", "info", "客户已有预约阶段、预约时间或预约门店信息。", "发送到店提醒", detailURL) + } + if item.BuyingStage == enums.SalesLeadStageAfterSales { + add("售后风险", "warning", "客户诉求进入售后或投诉相关阶段。", "转售后处理", detailURL) + } + if item.OwnerUserID == 0 && (item.Status == enums.SalesLeadStatusNew || item.Status == enums.SalesLeadStatusFollowing) { + add("未分配", "warning", "当前线索还没有负责人。", "认领或分配顾问", "/dashboard/sales-leads?owner=unassigned") + } + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrowStart := todayStart.AddDate(0, 0, 1) + if item.NextFollowUpAt != nil && (item.Status == enums.SalesLeadStatusNew || item.Status == enums.SalesLeadStatusFollowing) { + if item.NextFollowUpAt.Before(todayStart) { + add("逾期跟进", "danger", "下次跟进时间早于今天。", "立即联系客户", detailURL) + } else if item.NextFollowUpAt.Before(tomorrowStart) { + add("今日跟进", "info", "下次跟进时间在今天内。", "按计划跟进", detailURL) + } + } + if item.Phone == "" && item.WeChat == "" { + add("待补联系方式", "warning", "手机号和微信都为空,后续触达风险高。", "补齐联系方式", detailURL) + } else { + add("已留联系方式", "info", "客户已留下手机号或微信。", "安排跟进", detailURL) + } + if item.BudgetMax > 0 || item.BudgetMin > 0 { + add("有预算", "info", "线索已抽取到预算区间。", "按预算推荐产品", detailURL) + } + if item.BudgetMax >= 20000 || item.BudgetMin >= 20000 { + add("高预算", "hot", "预算上限或下限达到 20000 元以上。", "推荐高客单方案", detailURL) + } + if item.SourceChannel != "" { + add("渠道:"+item.SourceChannel, "info", "线索带有来源渠道标识。", "复盘渠道效果", "/dashboard") + } + if len(tags) > 8 { + tags = tags[:8] + } + return tags +} + +func BuildSalesLeadList(list []models.SalesLead) []response.SalesLeadResponse { + results := make([]response.SalesLeadResponse, 0, len(list)) + for i := range list { + if item := BuildSalesLead(&list[i]); item != nil { + results = append(results, *item) + } + } + return results +} + +func BuildLeadFollowUp(item *models.LeadFollowUp) *response.LeadFollowUpResponse { + if item == nil { + return nil + } + return &response.LeadFollowUpResponse{ + ID: item.ID, + LeadID: item.LeadID, + OperatorID: item.OperatorID, + OperatorName: item.OperatorName, + Content: item.Content, + NextAction: item.NextAction, + NextFollowUpAt: utils.FormatTimePtr(item.NextFollowUpAt), + CreatedAt: utils.FormatTime(item.CreatedAt), + } +} + +func BuildLeadFollowUps(list []models.LeadFollowUp) []response.LeadFollowUpResponse { + results := make([]response.LeadFollowUpResponse, 0, len(list)) + for i := range list { + if item := BuildLeadFollowUp(&list[i]); item != nil { + results = append(results, *item) + } + } + return results +} diff --git a/internal/builders/sales_lead_builder_test.go b/internal/builders/sales_lead_builder_test.go new file mode 100644 index 00000000..238a6016 --- /dev/null +++ b/internal/builders/sales_lead_builder_test.go @@ -0,0 +1,116 @@ +package builders + +import ( + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestBuildSalesLeadAutoTags(t *testing.T) { + overdue := time.Now().AddDate(0, 0, -1) + lead := &models.SalesLead{ + Phone: "13800000000", + BudgetMin: 20000, + InterestedProducts: "智能床垫", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentTimeText: "周末", + SourceChannel: "官网", + Status: enums.SalesLeadStatusFollowing, + NextFollowUpAt: &overdue, + } + + resp := BuildSalesLead(lead) + assertHasSalesLeadAutoTag(t, resp.AutoTags, "高意向") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "已预约") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "未分配") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "逾期跟进") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "已留联系方式") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "有预算") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "高预算") + assertHasSalesLeadAutoTag(t, resp.AutoTags, "渠道:官网") + assertHasSalesLeadAutoTagDetail(t, resp.AutoTagDetails, "高意向", "hot", "优先跟进") + assertHasSalesLeadAutoTagDetail(t, resp.AutoTagDetails, "逾期跟进", "danger", "立即联系客户") + assertHasSalesLeadAutoTagDetail(t, resp.AutoTagDetails, "未分配", "warning", "认领或分配顾问") + + lead.Status = enums.SalesLeadStatusVisited + visitedResp := BuildSalesLead(lead) + assertHasSalesLeadAutoTag(t, visitedResp.AutoTags, "已到店") +} + +func TestBuildSalesLeadIncludesRecentMessageSummary(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.Conversation{}, &models.Message{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + conversation := models.Conversation{ + CustomerName: "李静", + Status: enums.IMConversationStatusAIServing, + LastMessageSummary: "客户问老人腰不好怎么选床垫,AI 推荐分区支撑款。", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我想给爸妈选床垫,老人腰不好,预算两万以内,周末想去店里试一下。", + } + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + lead := &models.SalesLead{ + ConversationID: conversation.ID, + LastMessageID: message.ID, + Status: enums.SalesLeadStatusNew, + } + + resp := BuildSalesLead(lead) + if resp.LastMessageSummary != conversation.LastMessageSummary { + t.Fatalf("LastMessageSummary = %q, want %q", resp.LastMessageSummary, conversation.LastMessageSummary) + } + if resp.LastCustomerMessage != message.Content { + t.Fatalf("LastCustomerMessage = %q, want %q", resp.LastCustomerMessage, message.Content) + } +} + +func assertHasSalesLeadAutoTag(t *testing.T, tags []string, want string) { + t.Helper() + for _, tag := range tags { + if tag == want { + return + } + } + t.Fatalf("auto tags %#v missing %q", tags, want) +} + +func assertHasSalesLeadAutoTagDetail(t *testing.T, tags []response.SalesLeadAutoTag, label string, level string, action string) { + t.Helper() + for _, tag := range tags { + if tag.Label == label { + if tag.Level != level || tag.ActionLabel != action || tag.Reason == "" { + t.Fatalf("unexpected auto tag detail for %q: %#v", label, tag) + } + return + } + } + t.Fatalf("auto tag details %#v missing %q", tags, label) +} diff --git a/internal/events/notification_events.go b/internal/events/notification_events.go index b804c51b..f08e721d 100644 --- a/internal/events/notification_events.go +++ b/internal/events/notification_events.go @@ -11,6 +11,12 @@ type TicketCreatedEvent struct { OperatorID int64 } +type SalesLeadCreatedEvent struct { + LeadID int64 + ConversationID int64 + Reason string +} + type TicketAssignedEvent struct { TicketID int64 FromUserID int64 @@ -26,4 +32,5 @@ type ConversationAssignedEvent struct { OperatorID int64 Reason string AssignType string + ContextText string } diff --git a/internal/handlers/dashboard/conversation_handler.go b/internal/handlers/dashboard/conversation_handler.go index 50c4c822..0319532f 100644 --- a/internal/handlers/dashboard/conversation_handler.go +++ b/internal/handlers/dashboard/conversation_handler.go @@ -225,6 +225,44 @@ func ConversationPostClose(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func ConversationPostResume_ai(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionConversationTransfer) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + req := request.ResumeAIConversationRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ConversationService.ResumeAIConversation(req.ConversationID, req.Reason, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ConversationPostFollow_up_advice(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionConversationView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + req := request.ConversationFollowUpAdviceRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.ConversationService.BuildFollowUpAdvice(req.ConversationID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + func ConversationPostLink_customer(ctx *gin.Context) { operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionConversationLinkCustomer) if err != nil { diff --git a/internal/handlers/dashboard/dashboard_handler.go b/internal/handlers/dashboard/dashboard_handler.go index 3c7e7182..4ea83cfe 100644 --- a/internal/handlers/dashboard/dashboard_handler.go +++ b/internal/handlers/dashboard/dashboard_handler.go @@ -1,6 +1,7 @@ package dashboard import ( + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/httpx" "agent-desk/internal/services" @@ -14,3 +15,63 @@ func DashboardGetOverview(ctx *gin.Context) { rangeValue, _ := params.Get(ctx, "range") httpx.WriteJSON(ctx, services.DashboardService.GetOverview(rangeValue, i18nx.Locale(ctx))) } + +func DashboardGetDailyBusinessReport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + dateValue, _ := params.Get(ctx, "date") + httpx.WriteJSON(ctx, services.DashboardService.GetDailyBusinessReport(dateValue, i18nx.Locale(ctx))) +} + +func DashboardPostDailyBusinessReportSend(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + dateValue, _ := params.Get(ctx, "date") + resp, err := services.DashboardService.SendDailyBusinessReportWebhook(dateValue, i18nx.Locale(ctx), operator.UserID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DashboardGetAIQualityReport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + rangeValue, _ := params.Get(ctx, "range") + httpx.WriteJSON(ctx, services.DashboardService.GetAIQualityReport(rangeValue, i18nx.Locale(ctx))) +} + +func DashboardGetSalesFunnelReport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + rangeValue, _ := params.Get(ctx, "range") + httpx.WriteJSON(ctx, services.DashboardService.GetSalesFunnelReport(rangeValue, i18nx.Locale(ctx))) +} + +func DashboardGetBusinessTrendReport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + rangeValue, _ := params.Get(ctx, "range") + httpx.WriteJSON(ctx, services.DashboardService.GetBusinessTrendReport(rangeValue, i18nx.Locale(ctx))) +} + +func DashboardGetABTestReport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + rangeValue, _ := params.Get(ctx, "range") + httpx.WriteJSON(ctx, services.DashboardService.GetABTestReport(rangeValue, i18nx.Locale(ctx))) +} diff --git a/internal/handlers/dashboard/digital_store_handler.go b/internal/handlers/dashboard/digital_store_handler.go new file mode 100644 index 00000000..987fa992 --- /dev/null +++ b/internal/handlers/dashboard/digital_store_handler.go @@ -0,0 +1,299 @@ +package dashboard + +import ( + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +func DigitalStoreGetProfile(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetProfile()) +} + +func DigitalStoreGetTemplates(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.ListTemplates()) +} + +func DigitalStoreGetTemplate_export(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + templateCode, _ := params.Get(ctx, "templateCode") + resp, err := services.DigitalStoreProfileService.ExportTemplate(templateCode) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStoreGetTemplate_preview(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + templateCode, _ := params.Get(ctx, "templateCode") + resp, err := services.DigitalStoreProfileService.PreviewTemplate(templateCode) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostTemplate_import_preview(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreTemplateImportRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.PreviewImportedTemplate(req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStoreGetSetup_status(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetSetupStatus()) +} + +func DigitalStoreGetMaintenance_status(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetMaintenanceStatus()) +} + +func DigitalStoreGetKnowledge_assistant(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetKnowledgeAssistant()) +} + +func DigitalStoreGetTemplate_effect(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetTemplateEffect()) +} + +func DigitalStoreGetDelivery_report(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + publicBaseURL, _ := params.Get(ctx, "publicBaseUrl") + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetDeliveryReport(publicBaseURL)) +} + +func DigitalStoreGetDelivery_record_latest(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetLatestDeliveryRecord()) +} + +func DigitalStorePostDelivery_record_create(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreDeliveryRecordCreateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.CreateDeliveryRecord(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostDelivery_record_acceptance_result(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreAcceptanceResultCreateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.CreateAcceptanceResultRecord(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostProfile(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreProfileRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.UpdateProfile(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostEnsure_runtime(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.EnsureRuntime(operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostTest_webhook_notify(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.TestWebhookNotify(operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostTest_webhook_notify_scenarios(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.TestWebhookNotifyScenarios(operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostCleanup_demo_data(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.CleanupDemoData(operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostSeed_muse(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.SeedMuseProfile(operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostApply_template(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreApplyTemplateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.ApplyTemplate(req.TemplateCode, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostApply_imported_template(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DigitalStoreTemplateImportRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp, err := services.DigitalStoreProfileService.ApplyImportedTemplate(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, resp) +} + +func DigitalStorePostSync_knowledge(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionDigitalStoreUpdate); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.DigitalStoreProfileService.SyncKnowledgeFAQ(); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.DigitalStoreProfileService.GetProfile()) +} diff --git a/internal/handlers/dashboard/knowledge_faq_handler.go b/internal/handlers/dashboard/knowledge_faq_handler.go index 50b149f4..1e27a4bb 100644 --- a/internal/handlers/dashboard/knowledge_faq_handler.go +++ b/internal/handlers/dashboard/knowledge_faq_handler.go @@ -101,6 +101,7 @@ func KnowledgeFAQAnyList(ctx *gin.Context) { cnd := params.NewPagedSqlCnd(ctx, params.QueryFilter{ParamName: "knowledgeBaseId"}, params.QueryFilter{ParamName: "question", Op: params.Like}, + params.QueryFilter{ParamName: "status"}, params.QueryFilter{ParamName: "indexStatus"}, ).Desc("id") knowledgeBaseID, _ := params.GetInt64(ctx, "knowledgeBaseId") @@ -175,6 +176,24 @@ func KnowledgeFAQPostUpdate(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func KnowledgeFAQPostUpdate_status(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdateKnowledgeFAQStatusRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeFAQService.UpdateStatus(req.ID, req.Status, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func KnowledgeFAQPostDelete(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQDelete); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/knowledge_retrieve_log_handler.go b/internal/handlers/dashboard/knowledge_retrieve_log_handler.go index 84a152f4..e435ae76 100644 --- a/internal/handlers/dashboard/knowledge_retrieve_log_handler.go +++ b/internal/handlers/dashboard/knowledge_retrieve_log_handler.go @@ -3,6 +3,7 @@ package dashboard import ( "agent-desk/internal/builders" "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/httpx" "agent-desk/internal/services" @@ -33,16 +34,31 @@ func KnowledgeRetrieveLogAnyList(ctx *gin.Context) { if rerankEnabled, ok := params.GetInt64(ctx, "rerankEnabled"); ok { cnd.Where("rerank_enabled = ?", rerankEnabled > 0) } + if feedbackState, ok := params.Get(ctx, "feedbackState"); ok { + services.KnowledgeRetrieveLogService.ApplyFeedbackStateFilter(cnd, feedbackState) + } queryParams := params.NewQueryParams(ctx) queryParams.Cnd = *cnd list, paging := services.KnowledgeRetrieveLogService.FindPageByParams(queryParams) + ids := make([]int64, 0, len(list)) + for _, item := range list { + ids = append(ids, item.ID) + } + feedbackSummaries := services.KnowledgeRetrieveLogService.FindFeedbackSummariesByRetrieveLogIDs(ids) results := make([]response.KnowledgeRetrieveLogResponse, 0, len(list)) for _, item := range list { resp := builders.BuildKnowledgeRetrieveLog(&item) if knowledgeBase := services.KnowledgeBaseService.Get(item.KnowledgeBaseID); knowledgeBase != nil { resp.KnowledgeBaseName = knowledgeBase.Name } + if summary, ok := feedbackSummaries[item.ID]; ok { + resp.FeedbackCount = summary.FeedbackCount + resp.NegativeFeedbackCount = summary.NegativeFeedbackCount + resp.LatestFeedbackType = summary.LatestFeedbackType + resp.LatestFeedbackTypeName = summary.LatestFeedbackTypeName + resp.LatestFeedbackReason = summary.LatestFeedbackReason + } results = append(results, resp) } httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) @@ -75,8 +91,75 @@ func KnowledgeRetrieveLogGetBy(ctx *gin.Context) { hitResults = append(hitResults, builders.BuildKnowledgeRetrieveHitResponse(&item)) } + feedbacks := services.KnowledgeRetrieveLogService.FindFeedbacksByRetrieveLogID(id) + feedbackResults := make([]response.KnowledgeFeedbackResponse, 0, len(feedbacks)) + for _, item := range feedbacks { + feedbackResults = append(feedbackResults, builders.BuildKnowledgeFeedback(&item)) + } + httpx.WriteJSON(ctx, response.KnowledgeRetrieveLogDetailResponse{ - Log: logResp, - Hits: hitResults, + Log: logResp, + Hits: hitResults, + Feedbacks: feedbackResults, }) } + +func KnowledgeRetrieveLogPostFeedback_create(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentView) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + req := request.CreateKnowledgeFeedbackRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.KnowledgeRetrieveLogService.CreateFeedback(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildKnowledgeFeedback(item)) +} + +func KnowledgeRetrieveLogPostFaq_draft_create(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + req := request.CreateKnowledgeFAQDraftFromRetrieveLogRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.KnowledgeFAQService.CreateDraftFromRetrieveLog(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item)) +} + +func KnowledgeRetrieveLogPostFaq_draft_batch_create(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + req := request.BatchCreateKnowledgeFAQDraftsFromRetrieveLogsRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.KnowledgeFAQService.BatchCreateDraftsFromRetrieveLogs(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} diff --git a/internal/handlers/dashboard/product_handler.go b/internal/handlers/dashboard/product_handler.go new file mode 100644 index 00000000..1e9f0bb7 --- /dev/null +++ b/internal/handlers/dashboard/product_handler.go @@ -0,0 +1,167 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +func ProductPostList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ProductListRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + list, paging := services.ProductService.List(req) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildProductList(list), Page: paging}) +} + +func ProductGetBy(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + httpx.WriteJSON(ctx, builders.BuildProduct(services.ProductService.Get(id))) +} + +func ProductPostCreate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SaveProductRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.ProductService.Create(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildProduct(item)) +} + +func ProductPostUpdate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SaveProductRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ProductService.Update(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ProductPostUpdate_status(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdateProductStatusRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ProductService.UpdateStatus(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ProductPostDelete(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductDelete) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DeleteProductRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ProductService.Delete(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ProductPostReindex(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductReindex); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ReindexProductRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ProductService.Reindex(req.ID); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func ProductPostImport(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + file, err := ctx.FormFile("file") + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + reader, err := file.Open() + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + defer reader.Close() + result, err := services.ProductService.ImportCSV(reader, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + +func ProductPostSeed_muse(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionProductCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ProductService.SeedMuseProducts(operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} diff --git a/internal/handlers/dashboard/promotion_handler.go b/internal/handlers/dashboard/promotion_handler.go new file mode 100644 index 00000000..3b0df212 --- /dev/null +++ b/internal/handlers/dashboard/promotion_handler.go @@ -0,0 +1,167 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +func PromotionPostList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.PromotionListRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + list, paging := services.PromotionService.List(req) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildPromotionList(list), Page: paging}) +} + +func PromotionGetBy(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + httpx.WriteJSON(ctx, builders.BuildPromotion(services.PromotionService.Get(id))) +} + +func PromotionPostCreate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SavePromotionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.PromotionService.Create(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildPromotion(item)) +} + +func PromotionPostUpdate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SavePromotionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.PromotionService.Update(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func PromotionPostUpdate_status(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdatePromotionStatusRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.PromotionService.UpdateStatus(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func PromotionPostDelete(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionDelete) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DeletePromotionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.PromotionService.Delete(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func PromotionPostReindex(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionReindex); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ReindexPromotionRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.PromotionService.Reindex(req.ID); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func PromotionPostImport(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + file, err := ctx.FormFile("file") + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + reader, err := file.Open() + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + defer reader.Close() + result, err := services.PromotionService.ImportCSV(reader, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + +func PromotionPostSeed_muse(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionPromotionCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.PromotionService.SeedMusePromotions(operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} diff --git a/internal/handlers/dashboard/sales_lead_handler.go b/internal/handlers/dashboard/sales_lead_handler.go new file mode 100644 index 00000000..740d8e6a --- /dev/null +++ b/internal/handlers/dashboard/sales_lead_handler.go @@ -0,0 +1,443 @@ +package dashboard + +import ( + "bytes" + "encoding/csv" + "net/http" + "strconv" + "strings" + "time" + + "agent-desk/internal/builders" + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +func SalesLeadPostList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadListRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + list, paging := services.SalesLeadService.List(req) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildSalesLeadList(list), Page: paging}) +} + +func SalesLeadGetExport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadListRequest{ + Keyword: strings.TrimSpace(ctx.Query("keyword")), + Status: strings.TrimSpace(ctx.Query("status")), + Intent: strings.TrimSpace(ctx.Query("intent")), + TaskView: strings.TrimSpace(ctx.Query("taskView")), + FollowUpStatus: strings.TrimSpace(ctx.Query("followUpStatus")), + AppointmentStatus: strings.TrimSpace(ctx.Query("appointmentStatus")), + } + if ownerUserID, ok := params.GetInt64(ctx, "ownerUserId"); ok && ownerUserID != 0 { + req.OwnerUserID = &ownerUserID + } + + var buffer bytes.Buffer + buffer.WriteString("\xEF\xBB\xBF") + writer := csv.NewWriter(&buffer) + if err := writer.Write(salesLeadExportHeaders()); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ownerNames := map[int64]string{} + for _, item := range services.SalesLeadService.Export(req) { + if err := writer.Write(salesLeadExportRow(item, ownerNames)); err != nil { + httpx.WriteJSON(ctx, err) + return + } + } + writer.Flush() + if err := writer.Error(); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + filename := "sales-leads-" + time.Now().Format("20060102150405") + ".csv" + ctx.Header("Content-Type", "text/csv; charset=utf-8") + ctx.Header("Content-Disposition", `attachment; filename="`+filename+`"`) + ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes()) +} + +func SalesLeadGetBy(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + item := services.SalesLeadService.Get(id) + if item == nil { + httpx.WriteJSON(ctx, nil) + return + } + lead := builders.BuildSalesLead(item) + followUps := services.SalesLeadService.FindFollowUps(id) + detail := response.SalesLeadDetailResponse{ + Lead: *lead, + FollowUps: builders.BuildLeadFollowUps(followUps), + FollowUpAdvice: services.SalesLeadService.BuildFollowUpAdvice(item, followUps), + } + httpx.WriteJSON(ctx, &detail) +} + +func SalesLeadPostUpdate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdateSalesLeadRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.SalesLeadService.Update(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func SalesLeadPostUpdateStatus(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdateSalesLeadStatusRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.SalesLeadService.UpdateStatus(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func SalesLeadPostCrmSync(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SyncSalesLeadToCRMRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.SalesLeadService.SyncToCRM(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + +func SalesLeadPostAssign(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadAssign) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.AssignSalesLeadRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.SalesLeadService.Assign(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func SalesLeadPostClaimUnassigned(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadAssign) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.ClaimUnassignedSalesLeadsRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.SalesLeadService.ClaimUnassigned(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + +func SalesLeadPostFollowUpCreate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadFollowUp) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.CreateLeadFollowUpRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.SalesLeadService.CreateFollowUp(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildLeadFollowUp(item)) +} + +func SalesLeadPostFollowUpReminderSummary(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadFollowUpReminderRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.SalesLeadService.GetFollowUpReminderSummary(req)) +} + +func SalesLeadPostAppointmentSummary(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadAppointmentSummaryRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.SalesLeadService.GetAppointmentSummary(req)) +} + +func SalesLeadPostAppointmentReminderSend(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadFollowUp) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadAppointmentSummaryRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.SalesLeadService.SendAppointmentReminder(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + +func SalesLeadPostFollowUpReminderSend(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionSalesLeadFollowUp) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SalesLeadFollowUpReminderRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret, err := services.SalesLeadService.SendFollowUpReminder(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + +func salesLeadExportHeaders() []string { + return []string{ + "线索ID", + "客户姓名", + "手机号", + "微信", + "城市", + "地址/小区", + "预算下限", + "预算上限", + "意向产品", + "需求摘要", + "意向等级", + "购买阶段", + "预约时间", + "预约时间描述", + "预约门店", + "到店人数", + "预约备注", + "来源渠道", + "负责人ID", + "负责人", + "状态", + "下次跟进", + "会话ID", + "归并方式", + "归并说明", + "归并时间", + "创建时间", + "更新时间", + "备注", + } +} + +func salesLeadExportRow(item models.SalesLead, ownerNames map[int64]string) []string { + return []string{ + strconv.FormatInt(item.ID, 10), + item.CustomerName, + item.Phone, + item.WeChat, + item.City, + item.AddressHint, + formatInt64OrBlank(item.BudgetMin), + formatInt64OrBlank(item.BudgetMax), + item.InterestedProducts, + item.DemandSummary, + salesLeadIntentLabel(item.IntentLevel), + salesLeadStageLabel(item.BuyingStage), + utils.FormatTimePtr(item.AppointmentAt), + item.AppointmentTimeText, + item.AppointmentStore, + formatIntOrBlank(item.AppointmentPeople), + item.AppointmentRemark, + item.SourceChannel, + formatInt64OrBlank(item.OwnerUserID), + salesLeadOwnerName(item.OwnerUserID, ownerNames), + salesLeadStatusLabel(item.Status), + utils.FormatTimePtr(item.NextFollowUpAt), + formatInt64OrBlank(item.ConversationID), + salesLeadMergeKeyLabel(item.MergeKey), + item.MergeReason, + utils.FormatTimePtr(item.MergedAt), + utils.FormatTime(item.CreatedAt), + utils.FormatTime(item.UpdatedAt), + item.Remark, + } +} + +func salesLeadMergeKeyLabel(value string) string { + switch strings.TrimSpace(value) { + case "new": + return "新建" + case "conversation": + return "同会话" + case "phone": + return "同手机号" + case "wechat": + return "同微信" + case "customer": + return "同客户" + default: + return strings.TrimSpace(value) + } +} + +func salesLeadOwnerName(ownerUserID int64, ownerNames map[int64]string) string { + if ownerUserID <= 0 { + return "" + } + if value, ok := ownerNames[ownerUserID]; ok { + return value + } + if owner := services.UserService.Get(ownerUserID); owner != nil { + ownerNames[ownerUserID] = owner.Username + return owner.Username + } + ownerNames[ownerUserID] = "" + return "" +} + +func formatInt64OrBlank(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + +func formatIntOrBlank(value int) string { + if value <= 0 { + return "" + } + return strconv.Itoa(value) +} + +func salesLeadIntentLabel(value enums.SalesLeadIntent) string { + switch value { + case enums.SalesLeadIntentHigh: + return "高意向" + case enums.SalesLeadIntentMedium: + return "中意向" + case enums.SalesLeadIntentLow: + return "低意向" + default: + return "未知" + } +} + +func salesLeadStageLabel(value enums.SalesLeadStage) string { + switch value { + case enums.SalesLeadStageConsulting: + return "咨询了解" + case enums.SalesLeadStageComparing: + return "对比决策" + case enums.SalesLeadStageAppointment: + return "预约到店" + case enums.SalesLeadStageReadyToBuy: + return "准备购买" + case enums.SalesLeadStageAfterSales: + return "售后问题" + default: + return "未知" + } +} + +func salesLeadStatusLabel(value enums.SalesLeadStatus) string { + switch value { + case enums.SalesLeadStatusNew: + return "新线索" + case enums.SalesLeadStatusFollowing: + return "跟进中" + case enums.SalesLeadStatusVisited: + return "已到店" + case enums.SalesLeadStatusConverted: + return "已转化" + case enums.SalesLeadStatusInvalid: + return "无效" + case enums.SalesLeadStatusClosed: + return "已关闭" + default: + return string(value) + } +} diff --git a/internal/handlers/dashboard/sales_lead_handler_test.go b/internal/handlers/dashboard/sales_lead_handler_test.go new file mode 100644 index 00000000..a8b34db7 --- /dev/null +++ b/internal/handlers/dashboard/sales_lead_handler_test.go @@ -0,0 +1,30 @@ +package dashboard + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" +) + +func TestSalesLeadExportIncludesMergeExplanation(t *testing.T) { + mergedAt := time.Date(2026, 7, 6, 10, 30, 0, 0, time.Local) + lead := models.SalesLead{ + ID: 12, + CustomerName: "王先生", + MergeKey: "phone", + MergeReason: "手机号 13800001111 命中活跃线索 #12,跨会话合并到该线索。", + MergedAt: &mergedAt, + } + + headers := strings.Join(salesLeadExportHeaders(), ",") + if !strings.Contains(headers, "归并方式") || !strings.Contains(headers, "归并说明") || !strings.Contains(headers, "归并时间") { + t.Fatalf("export headers should include merge explanation fields: %s", headers) + } + + row := strings.Join(salesLeadExportRow(lead, map[int64]string{}), "\n") + if !strings.Contains(row, "同手机号") || !strings.Contains(row, "手机号 13800001111") || !strings.Contains(row, "2026-07-06 10:30:00") { + t.Fatalf("export row should include merge explanation values: %s", row) + } +} diff --git a/internal/migration/000002_init_auth_data.go b/internal/migration/000002_init_auth_data.go index 177b5379..9ae82c9f 100644 --- a/internal/migration/000002_init_auth_data.go +++ b/internal/migration/000002_init_auth_data.go @@ -186,7 +186,7 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { username := constants.BootstrapAdminUsername nickname := constants.BootstrapAdminNickname - password := constants.BootstrapAdminPassword + password := constants.BootstrapAdminInitialPassword() if strings.TrimSpace(password) == "" { password = "ChangeMe123!" diff --git a/internal/migration/000008_sync_sales_lead_permissions.go b/internal/migration/000008_sync_sales_lead_permissions.go new file mode 100644 index 00000000..4bba4fb3 --- /dev/null +++ b/internal/migration/000008_sync_sales_lead_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(8, "sync sales lead permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/migration/000009_sync_product_permissions.go b/internal/migration/000009_sync_product_permissions.go new file mode 100644 index 00000000..12bb5dec --- /dev/null +++ b/internal/migration/000009_sync_product_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(9, "sync product permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/migration/000010_sync_digital_store_permissions.go b/internal/migration/000010_sync_digital_store_permissions.go new file mode 100644 index 00000000..4be289a1 --- /dev/null +++ b/internal/migration/000010_sync_digital_store_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(10, "sync digital store permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/migration/000011_sync_promotion_permissions.go b/internal/migration/000011_sync_promotion_permissions.go new file mode 100644 index 00000000..25df958f --- /dev/null +++ b/internal/migration/000011_sync_promotion_permissions.go @@ -0,0 +1,21 @@ +package migration + +import "github.com/mlogclub/simple/sqls" + +func init() { + register(11, "sync promotion permissions", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + permissions, err := ensurePermissions(ctx.Tx) + if err != nil { + return err + } + + roles, err := ensureRoles(ctx.Tx) + if err != nil { + return err + } + + return ensureRolePermissions(ctx.Tx, roles, permissions) + }) + }) +} diff --git a/internal/migration/000012_create_digital_store_delivery_records.go b/internal/migration/000012_create_digital_store_delivery_records.go new file mode 100644 index 00000000..10902bd0 --- /dev/null +++ b/internal/migration/000012_create_digital_store_delivery_records.go @@ -0,0 +1,13 @@ +package migration + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(12, "create digital store delivery records", func() error { + return sqls.DB().AutoMigrate(&models.DigitalStoreDeliveryRecord{}) + }) +} diff --git a/internal/migration/000013_add_sales_lead_merge_explanation.go b/internal/migration/000013_add_sales_lead_merge_explanation.go new file mode 100644 index 00000000..aa0fe593 --- /dev/null +++ b/internal/migration/000013_add_sales_lead_merge_explanation.go @@ -0,0 +1,13 @@ +package migration + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(13, "add sales lead merge explanation", func() error { + return sqls.DB().AutoMigrate(&models.SalesLead{}) + }) +} diff --git a/internal/migration/000014_add_product_industry_attributes.go b/internal/migration/000014_add_product_industry_attributes.go new file mode 100644 index 00000000..95284b4f --- /dev/null +++ b/internal/migration/000014_add_product_industry_attributes.go @@ -0,0 +1,13 @@ +package migration + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(14, "add product industry attributes", func() error { + return sqls.DB().AutoMigrate(&models.Product{}) + }) +} diff --git a/internal/models/models.go b/internal/models/models.go index 30511865..cf5a4d2b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -14,6 +14,10 @@ var Models = []any{ &Customer{}, &CustomerIdentity{}, &CustomerContact{}, + &SalesLead{}, + &LeadFollowUp{}, + &Product{}, + &Promotion{}, &Role{}, &Permission{}, &UserRole{}, @@ -63,6 +67,7 @@ var Models = []any{ &AIWorkflowNodeRun{}, &ConversationInterrupt{}, &SystemConfig{}, + &DigitalStoreDeliveryRecord{}, } type Migration struct { @@ -88,6 +93,30 @@ type SystemConfig struct { AuditFields } +// DigitalStoreDeliveryRecord 记录单商家交付/上线验收的归档快照。 +type DigitalStoreDeliveryRecord struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + BrandName string `gorm:"type:varchar(100);not null;default:'';index"` + StoreName string `gorm:"type:varchar(150);not null;default:'';index"` + Ready bool `gorm:"not null;default:false;index"` + AcceptanceStatus string `gorm:"type:varchar(30);not null;default:'';index"` + AcceptanceSummary string `gorm:"type:text"` + AcceptanceCommand string `gorm:"type:varchar(500);not null;default:''"` + ScenarioTotal int `gorm:"type:int;not null;default:0"` + PassedTotal int `gorm:"type:int;not null;default:0"` + FailedTotal int `gorm:"type:int;not null;default:0"` + AcceptanceStartedAt *time.Time `gorm:"type:datetime;index"` + AcceptanceFinishedAt *time.Time `gorm:"type:datetime;index"` + AcceptanceResultJSON string `gorm:"type:longtext"` + DashboardURL string `gorm:"type:varchar(500);not null;default:''"` + ChatURL string `gorm:"type:varchar(500);not null;default:''"` + WebChannelCode string `gorm:"type:varchar(100);not null;default:'';index"` + ReportMarkdown string `gorm:"type:longtext"` + ReportJSON string `gorm:"type:longtext"` + Status enums.Status `gorm:"type:int;not null;default:0;index"` + AuditFields +} + // TicketNoSequence 工单号日序列表。 // // 每天一条记录,NextSeq 表示当日下一次可分配的序号。 @@ -224,6 +253,100 @@ type CustomerContact struct { AuditFields } +// SalesLead 销售线索。 +// +// 用于承接 AI 数字店长从会话中识别出的客户购买意向、联系方式和跟进状态。 +type SalesLead struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + CustomerID int64 `gorm:"type:bigint;not null;default:0;index"` + ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` + CustomerName string `gorm:"type:varchar(100);not null;default:'';index"` + Phone string `gorm:"type:varchar(32);not null;default:'';index"` + WeChat string `gorm:"type:varchar(100);not null;default:'';index"` + City string `gorm:"type:varchar(100);not null;default:'';index"` + AddressHint string `gorm:"type:varchar(255);not null;default:''"` + BudgetMin int64 `gorm:"type:bigint;not null;default:0;index"` + BudgetMax int64 `gorm:"type:bigint;not null;default:0;index"` + InterestedProducts string `gorm:"type:varchar(500);not null;default:''"` + DemandSummary string `gorm:"type:text"` + IntentLevel enums.SalesLeadIntent `gorm:"type:varchar(30);not null;default:'unknown';index"` + BuyingStage enums.SalesLeadStage `gorm:"type:varchar(30);not null;default:'unknown';index"` + AppointmentAt *time.Time `gorm:"type:datetime;index"` + AppointmentTimeText string `gorm:"type:varchar(100);not null;default:''"` + AppointmentStore string `gorm:"type:varchar(150);not null;default:'';index"` + AppointmentPeople int `gorm:"type:int;not null;default:0"` + AppointmentRemark string `gorm:"type:text"` + SourceChannel string `gorm:"type:varchar(50);not null;default:'';index"` + OwnerUserID int64 `gorm:"type:bigint;not null;default:0;index"` + Status enums.SalesLeadStatus `gorm:"type:varchar(30);not null;default:'new';index"` + NextFollowUpAt *time.Time `gorm:"type:datetime;index"` + LastMessageID int64 `gorm:"type:bigint;not null;default:0;index"` + MergeKey string `gorm:"type:varchar(50);not null;default:'';index"` + MergeReason string `gorm:"type:text"` + MergedAt *time.Time `gorm:"type:datetime;index"` + Remark string `gorm:"type:text"` + AuditFields +} + +// LeadFollowUp 销售线索跟进记录。 +type LeadFollowUp struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + LeadID int64 `gorm:"type:bigint;not null;index"` + OperatorID int64 `gorm:"type:bigint;not null;default:0;index"` + OperatorName string `gorm:"type:varchar(100);not null;default:''"` + Content string `gorm:"type:text"` + NextAction string `gorm:"type:varchar(255);not null;default:''"` + NextFollowUpAt *time.Time `gorm:"type:datetime;index"` + CreatedAt time.Time `gorm:"type:datetime;not null;index"` +} + +// Product 商家产品库。 +// +// 用于 AI 数字店长按预算、人群和场景进行导购推荐;可同步为 FAQ 知识块参与 RAG。 +type Product struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(150);not null;default:'';index"` + Category string `gorm:"type:varchar(100);not null;default:'';index"` + PriceMin int64 `gorm:"type:bigint;not null;default:0;index"` + PriceMax int64 `gorm:"type:bigint;not null;default:0;index"` + SellingPoints string `gorm:"type:text"` + SuitablePeople string `gorm:"type:text"` + UnsuitablePeople string `gorm:"type:text"` + Scenarios string `gorm:"type:text"` + Specs string `gorm:"type:text"` + IndustryAttributes string `gorm:"type:text"` + ImageURL string `gorm:"type:varchar(1024);not null;default:''"` + Priority int `gorm:"type:int;not null;default:0;index"` + KnowledgeBaseID int64 `gorm:"type:bigint;not null;default:0;index"` + KnowledgeFAQID int64 `gorm:"type:bigint;not null;default:0;index"` + Status enums.Status `gorm:"type:int;not null;default:0;index"` + Remark string `gorm:"type:text"` + AuditFields +} + +// Promotion 商家活动/优惠库。 +// +// 用于 AI 数字店长在推荐、预约和留资场景中引用当前有效权益;可同步为 FAQ 知识块参与 RAG。 +type Promotion struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(150);not null;default:'';index"` + PromotionType string `gorm:"type:varchar(80);not null;default:'';index"` + Description string `gorm:"type:text"` + ApplicableProducts string `gorm:"type:varchar(500);not null;default:'';index"` + StartAt *time.Time `gorm:"type:datetime;index"` + EndAt *time.Time `gorm:"type:datetime;index"` + DiscountRule string `gorm:"type:text"` + StoreBenefit string `gorm:"type:text"` + AppointmentBenefit string `gorm:"type:text"` + ScriptSuggestion string `gorm:"type:text"` + Priority int `gorm:"type:int;not null;default:0;index"` + KnowledgeBaseID int64 `gorm:"type:bigint;not null;default:0;index"` + KnowledgeFAQID int64 `gorm:"type:bigint;not null;default:0;index"` + Status enums.Status `gorm:"type:int;not null;default:0;index"` + Remark string `gorm:"type:text"` + AuditFields +} + // Role 角色定义。 type Role struct { ID int64 `gorm:"primaryKey;autoIncrement"` diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 0e181fc1..cd0477e1 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -17,6 +17,7 @@ type Config struct { Storage StorageConfig `yaml:"storage"` VectorDB VectorDBConfig `yaml:"vectorDB"` MCP MCPConfig `yaml:"mcp"` + Notify NotifyConfig `yaml:"notify"` WxWork WxWorkConfig `yaml:"wxWork"` OIDC OIDCConfig `yaml:"oidc"` CustomerSession CustomerSessionConfig `yaml:"customerSession"` @@ -41,6 +42,27 @@ type WxWorkNotifyConfig struct { DuplicateCheckInterval int `yaml:"duplicateCheckInterval"` } +type NotifyConfig struct { + Webhook WebhookNotifyConfig `yaml:"webhook"` + DailyReport DailyReportNotifyConfig `yaml:"dailyReport"` +} + +type WebhookNotifyConfig struct { + Enabled bool `yaml:"enabled"` + URL string `yaml:"url"` + Format string `yaml:"format"` + Secret string `yaml:"secret"` + TimeoutMS int `yaml:"timeoutMs"` + Headers map[string]string `yaml:"headers"` +} + +type DailyReportNotifyConfig struct { + Enabled bool `yaml:"enabled"` + Cron string `yaml:"cron"` + DateOffsetDays int `yaml:"dateOffsetDays"` + AllowDuplicate bool `yaml:"allowDuplicate"` +} + type ServerConfig struct { Port int `yaml:"port"` CORS CORSConfig `yaml:"cors"` diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 07c46d65..81672bee 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -50,6 +50,9 @@ mcp: servers: system: endpoint: http://127.0.0.1:8083/api/mcp +notify: + dailyReport: + allowDuplicate: false `) if err := os.WriteFile(path, content, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) @@ -58,6 +61,7 @@ mcp: 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") + t.Setenv("AGENT_DESK_NOTIFY_DAILYREPORT_ALLOWDUPLICATE", "true") cfg, err := Load(path) if err != nil { @@ -79,4 +83,7 @@ mcp: 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) } + if !cfg.Notify.DailyReport.AllowDuplicate { + t.Fatal("Notify.DailyReport.AllowDuplicate=false want true") + } } diff --git a/internal/pkg/constants/auth.go b/internal/pkg/constants/auth.go index 34f9402c..4757e065 100644 --- a/internal/pkg/constants/auth.go +++ b/internal/pkg/constants/auth.go @@ -1,5 +1,10 @@ package constants +import ( + "os" + "strings" +) + const ( RoleCodeSuperAdmin = "super_admin" // 超管 RoleCodeAdmin = "admin" // 管理员 @@ -21,6 +26,17 @@ const ( BootstrapAdminNickname = "Super Admin" ) +const ( + EnvBootstrapAdminPassword = "AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD" +) + +func BootstrapAdminInitialPassword() string { + if password := strings.TrimSpace(os.Getenv(EnvBootstrapAdminPassword)); password != "" { + return password + } + return BootstrapAdminPassword +} + // Permission 权限结构体 type Permission struct { Name string @@ -109,6 +125,30 @@ var ( 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} + // 销售线索相关权限 + PermissionSalesLeadView = Permission{Name: "查看销售线索", Code: "salesLead.view", Type: "api", GroupName: "salesLead", Method: "POST", APIPath: "/api/dashboard/sales-lead/list", SortNo: 665} + PermissionSalesLeadUpdate = Permission{Name: "更新销售线索", Code: "salesLead.update", Type: "api", GroupName: "salesLead", Method: "POST", APIPath: "/api/dashboard/sales-lead/update", SortNo: 666} + PermissionSalesLeadAssign = Permission{Name: "分配销售线索", Code: "salesLead.assign", Type: "api", GroupName: "salesLead", Method: "POST", APIPath: "/api/dashboard/sales-lead/assign", SortNo: 667} + PermissionSalesLeadFollowUp = Permission{Name: "跟进销售线索", Code: "salesLead.followUp", Type: "api", GroupName: "salesLead", Method: "POST", APIPath: "/api/dashboard/sales-lead/follow-up/create", SortNo: 668} + + // 产品库相关权限 + PermissionProductView = Permission{Name: "查看产品库", Code: "product.view", Type: "api", GroupName: "product", Method: "POST", APIPath: "/api/dashboard/product/list", SortNo: 669} + PermissionProductCreate = Permission{Name: "创建产品", Code: "product.create", Type: "api", GroupName: "product", Method: "POST", APIPath: "/api/dashboard/product/create", SortNo: 670} + PermissionProductUpdate = Permission{Name: "更新产品", Code: "product.update", Type: "api", GroupName: "product", Method: "POST", APIPath: "/api/dashboard/product/update", SortNo: 671} + PermissionProductDelete = Permission{Name: "删除产品", Code: "product.delete", Type: "api", GroupName: "product", Method: "POST", APIPath: "/api/dashboard/product/delete", SortNo: 672} + PermissionProductReindex = Permission{Name: "重建产品索引", Code: "product.reindex", Type: "api", GroupName: "product", Method: "POST", APIPath: "/api/dashboard/product/reindex", SortNo: 673} + + // 活动/优惠库相关权限 + PermissionPromotionView = Permission{Name: "查看活动库", Code: "promotion.view", Type: "api", GroupName: "promotion", Method: "POST", APIPath: "/api/dashboard/promotion/list", SortNo: 674} + PermissionPromotionCreate = Permission{Name: "创建活动", Code: "promotion.create", Type: "api", GroupName: "promotion", Method: "POST", APIPath: "/api/dashboard/promotion/create", SortNo: 675} + PermissionPromotionUpdate = Permission{Name: "更新活动", Code: "promotion.update", Type: "api", GroupName: "promotion", Method: "POST", APIPath: "/api/dashboard/promotion/update", SortNo: 676} + PermissionPromotionDelete = Permission{Name: "删除活动", Code: "promotion.delete", Type: "api", GroupName: "promotion", Method: "POST", APIPath: "/api/dashboard/promotion/delete", SortNo: 677} + PermissionPromotionReindex = Permission{Name: "重建活动索引", Code: "promotion.reindex", Type: "api", GroupName: "promotion", Method: "POST", APIPath: "/api/dashboard/promotion/reindex", SortNo: 678} + + // AI 数字店长配置权限 + PermissionDigitalStoreView = Permission{Name: "查看数字店长配置", Code: "digitalStore.view", Type: "api", GroupName: "digitalStore", Method: "GET", APIPath: "/api/dashboard/digital-store/profile", SortNo: 679} + PermissionDigitalStoreUpdate = Permission{Name: "更新数字店长配置", Code: "digitalStore.update", Type: "api", GroupName: "digitalStore", Method: "POST", APIPath: "/api/dashboard/digital-store/profile", SortNo: 680} + // 客服相关权限 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} @@ -227,6 +267,22 @@ var Permissions = []Permission{ PermissionCustomerCreate, PermissionCustomerUpdate, PermissionCustomerDelete, + PermissionSalesLeadView, + PermissionSalesLeadUpdate, + PermissionSalesLeadAssign, + PermissionSalesLeadFollowUp, + PermissionProductView, + PermissionProductCreate, + PermissionProductUpdate, + PermissionProductDelete, + PermissionProductReindex, + PermissionPromotionView, + PermissionPromotionCreate, + PermissionPromotionUpdate, + PermissionPromotionDelete, + PermissionPromotionReindex, + PermissionDigitalStoreView, + PermissionDigitalStoreUpdate, PermissionAgentView, PermissionAgentCreate, PermissionAgentUpdate, @@ -356,6 +412,10 @@ var builtinPermissionResourceLabels = map[string]string{ "company": "companies", "channel": "channels", "customer": "customers", + "salesLead": "sales leads", + "product": "products", + "promotion": "promotions", + "digitalStore": "digital store profiles", "agent": "agents", "agentTeam": "agent teams", "agentTeamSchedule": "agent team schedules", @@ -381,6 +441,8 @@ var builtinPermissionNameOverrides = map[string]string{ "agentTeamSchedule.batchGenerate": "Batch generate agent team schedules", "mcp.view": "View MCP debug information", "mcp.call": "Call MCP tools", + "product.reindex": "Reindex products", + "promotion.reindex": "Reindex promotions", } type RoleSpec struct { @@ -411,6 +473,10 @@ var RolePermissions = map[string][]Permission{ PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete, PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate, PermissionChannelDelete, PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate, PermissionCustomerDelete, + PermissionSalesLeadView, PermissionSalesLeadUpdate, PermissionSalesLeadAssign, PermissionSalesLeadFollowUp, + PermissionProductView, PermissionProductCreate, PermissionProductUpdate, PermissionProductDelete, PermissionProductReindex, + PermissionPromotionView, PermissionPromotionCreate, PermissionPromotionUpdate, PermissionPromotionDelete, PermissionPromotionReindex, + PermissionDigitalStoreView, PermissionDigitalStoreUpdate, PermissionAgentView, PermissionAgentCreate, PermissionAgentUpdate, PermissionAgentDelete, PermissionAgentUpdateStatus, PermissionAgentConfig, PermissionAgentTeamView, PermissionAgentTeamCreate, PermissionAgentTeamUpdate, PermissionAgentTeamDelete, PermissionAgentTeamScheduleView, PermissionAgentTeamScheduleCreate, PermissionAgentTeamScheduleUpdate, PermissionAgentTeamScheduleDelete, PermissionAgentTeamScheduleBatchGenerate, @@ -432,6 +498,10 @@ var RolePermissions = map[string][]Permission{ PermissionCompanyView, PermissionChannelView, PermissionChannelCreate, PermissionChannelUpdate, PermissionCustomerView, PermissionCustomerCreate, PermissionCustomerUpdate, + PermissionSalesLeadView, PermissionSalesLeadUpdate, PermissionSalesLeadAssign, PermissionSalesLeadFollowUp, + PermissionProductView, PermissionProductCreate, PermissionProductUpdate, PermissionProductReindex, + PermissionPromotionView, PermissionPromotionCreate, PermissionPromotionUpdate, PermissionPromotionReindex, + PermissionDigitalStoreView, PermissionDigitalStoreUpdate, PermissionAgentView, PermissionAgentUpdate, PermissionAgentTeamView, PermissionAgentTeamScheduleView, PermissionAgentTeamScheduleCreate, PermissionAgentTeamScheduleUpdate, PermissionAgentTeamScheduleDelete, PermissionAgentTeamScheduleBatchGenerate, @@ -452,6 +522,10 @@ var RolePermissions = map[string][]Permission{ PermissionCompanyView, PermissionChannelView, PermissionCustomerView, + PermissionSalesLeadView, PermissionSalesLeadUpdate, PermissionSalesLeadFollowUp, + PermissionProductView, + PermissionPromotionView, + PermissionDigitalStoreView, PermissionAssetView, PermissionAgentView, PermissionAgentTeamView, diff --git a/internal/pkg/dto/request/conversation_request.go b/internal/pkg/dto/request/conversation_request.go index cd2d1a40..0faa2e74 100644 --- a/internal/pkg/dto/request/conversation_request.go +++ b/internal/pkg/dto/request/conversation_request.go @@ -39,6 +39,15 @@ type CloseConversationRequest struct { CloseReason string `json:"closeReason"` } +type ResumeAIConversationRequest struct { + ConversationID int64 `json:"conversationId"` + Reason string `json:"reason"` +} + +type ConversationFollowUpAdviceRequest struct { + ConversationID int64 `json:"conversationId"` +} + type ReadConversationRequest struct { ConversationID int64 `json:"conversationId"` MessageID int64 `json:"messageId"` diff --git a/internal/pkg/dto/request/digital_store_request.go b/internal/pkg/dto/request/digital_store_request.go new file mode 100644 index 00000000..6467f96f --- /dev/null +++ b/internal/pkg/dto/request/digital_store_request.go @@ -0,0 +1,76 @@ +package request + +type DigitalStoreProfileRequest struct { + BrandName string `json:"brandName"` + Industry string `json:"industry"` + StoreName string `json:"storeName"` + StoreAddress string `json:"storeAddress"` + BusinessHours string `json:"businessHours"` + ContactPhone string `json:"contactPhone"` + ServiceWeChat string `json:"serviceWechat"` + EnterpriseWebhookURL string `json:"enterpriseWebhookUrl"` + AIManagerName string `json:"aiManagerName"` + AIPersona string `json:"aiPersona"` + ReplyStyle string `json:"replyStyle"` + ForbiddenClaims string `json:"forbiddenClaims"` + HandoffPolicy string `json:"handoffPolicy"` + AppointmentPolicy string `json:"appointmentPolicy"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Initialized bool `json:"initialized"` +} + +type DigitalStoreDeliveryRecordCreateRequest struct { + PublicBaseURL string `json:"publicBaseUrl"` + AcceptanceStatus string `json:"acceptanceStatus"` + AcceptanceSummary string `json:"acceptanceSummary"` +} + +type DigitalStoreAcceptanceScenarioResultRequest struct { + Code string `json:"code"` + Title string `json:"title"` + Passed bool `json:"passed"` + Reason string `json:"reason"` + FailureType string `json:"failureType"` + Detail string `json:"detail"` + Suggestion string `json:"suggestion"` + ConversationID int64 `json:"conversationId"` + ConversationURL string `json:"conversationUrl"` + Reply string `json:"reply"` + ExpectedKeywords []string `json:"expectedKeywords"` + MatchedKeywords []string `json:"matchedKeywords"` + MissingKeywords []string `json:"missingKeywords"` + BannedKeywords []string `json:"bannedKeywords"` + MatchedBanned string `json:"matchedBanned"` +} + +type DigitalStoreAcceptanceResultCreateRequest struct { + PublicBaseURL string `json:"publicBaseUrl"` + Command string `json:"command"` + ScenarioTotal int `json:"scenarioTotal"` + PassedTotal int `json:"passedTotal"` + FailedTotal int `json:"failedTotal"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + Results []DigitalStoreAcceptanceScenarioResultRequest `json:"results"` +} + +type DigitalStoreApplyTemplateRequest struct { + TemplateCode string `json:"templateCode"` +} + +type DigitalStoreTemplateImportRequest struct { + SchemaVersion string `json:"schemaVersion"` + ExportedAt string `json:"exportedAt"` + Template DigitalStoreTemplateImportMetaRequest `json:"template"` + Profile DigitalStoreProfileRequest `json:"profile"` + Products []SaveProductRequest `json:"products"` + Promotions []SavePromotionRequest `json:"promotions"` +} + +type DigitalStoreTemplateImportMetaRequest struct { + Code string `json:"code"` + Name string `json:"name"` + Industry string `json:"industry"` + Version string `json:"version"` + Description string `json:"description"` +} diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index f0d20b02..a085ac32 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -79,6 +79,11 @@ type UpdateKnowledgeFAQRequest struct { CreateKnowledgeFAQRequest } +type UpdateKnowledgeFAQStatusRequest struct { + ID int64 `json:"id"` + Status enums.Status `json:"status"` +} + type BatchMoveKnowledgeFAQRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` DirectoryID int64 `json:"directoryId"` @@ -135,3 +140,18 @@ type CreateKnowledgeFeedbackRequest struct { FeedbackReason string `json:"feedbackReason"` Remark string `json:"remark"` } + +type CreateKnowledgeFAQDraftFromRetrieveLogRequest struct { + RetrieveLogID int64 `json:"retrieveLogId"` + Answer string `json:"answer"` + Remark string `json:"remark"` +} + +type BatchCreateKnowledgeFAQDraftsFromRetrieveLogsRequest struct { + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + AnswerStatuses []int `json:"answerStatuses"` + IncludeNegativeFeedbacks bool `json:"includeNegativeFeedbacks"` + Limit int `json:"limit"` + Answer string `json:"answer"` + Remark string `json:"remark"` +} diff --git a/internal/pkg/dto/request/product_request.go b/internal/pkg/dto/request/product_request.go new file mode 100644 index 00000000..846cc284 --- /dev/null +++ b/internal/pkg/dto/request/product_request.go @@ -0,0 +1,62 @@ +package request + +type ProductListRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Keyword string `json:"keyword"` + Category string `json:"category"` + Status *int `json:"status"` +} + +func (r ProductListRequest) GetPage() int { + if r.Page <= 0 { + return 1 + } + return r.Page +} + +func (r ProductListRequest) GetLimit() int { + if r.Limit <= 0 { + return 20 + } + if r.Limit > 100 { + return 100 + } + return r.Limit +} + +func (r ProductListRequest) Offset() int { + return (r.GetPage() - 1) * r.GetLimit() +} + +type SaveProductRequest struct { + ID int64 `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + PriceMin int64 `json:"priceMin"` + PriceMax int64 `json:"priceMax"` + SellingPoints string `json:"sellingPoints"` + SuitablePeople string `json:"suitablePeople"` + UnsuitablePeople string `json:"unsuitablePeople"` + Scenarios string `json:"scenarios"` + Specs string `json:"specs"` + IndustryAttributes string `json:"industryAttributes"` + ImageURL string `json:"imageUrl"` + Priority int `json:"priority"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +type DeleteProductRequest struct { + ID int64 `json:"id"` +} + +type UpdateProductStatusRequest struct { + ID int64 `json:"id"` + Status int `json:"status"` +} + +type ReindexProductRequest struct { + ID int64 `json:"id"` +} diff --git a/internal/pkg/dto/request/promotion_request.go b/internal/pkg/dto/request/promotion_request.go new file mode 100644 index 00000000..304fd61e --- /dev/null +++ b/internal/pkg/dto/request/promotion_request.go @@ -0,0 +1,62 @@ +package request + +type PromotionListRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Keyword string `json:"keyword"` + PromotionType string `json:"promotionType"` + ActiveOnly bool `json:"activeOnly"` + Status *int `json:"status"` +} + +func (r PromotionListRequest) GetPage() int { + if r.Page <= 0 { + return 1 + } + return r.Page +} + +func (r PromotionListRequest) GetLimit() int { + if r.Limit <= 0 { + return 20 + } + if r.Limit > 100 { + return 100 + } + return r.Limit +} + +func (r PromotionListRequest) Offset() int { + return (r.GetPage() - 1) * r.GetLimit() +} + +type SavePromotionRequest struct { + ID int64 `json:"id"` + Name string `json:"name"` + PromotionType string `json:"promotionType"` + Description string `json:"description"` + ApplicableProducts string `json:"applicableProducts"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + DiscountRule string `json:"discountRule"` + StoreBenefit string `json:"storeBenefit"` + AppointmentBenefit string `json:"appointmentBenefit"` + ScriptSuggestion string `json:"scriptSuggestion"` + Priority int `json:"priority"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +type DeletePromotionRequest struct { + ID int64 `json:"id"` +} + +type UpdatePromotionStatusRequest struct { + ID int64 `json:"id"` + Status int `json:"status"` +} + +type ReindexPromotionRequest struct { + ID int64 `json:"id"` +} diff --git a/internal/pkg/dto/request/sales_lead_request.go b/internal/pkg/dto/request/sales_lead_request.go new file mode 100644 index 00000000..652c13d9 --- /dev/null +++ b/internal/pkg/dto/request/sales_lead_request.go @@ -0,0 +1,156 @@ +package request + +type SalesLeadListRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Keyword string `json:"keyword"` + Status string `json:"status"` + Intent string `json:"intent"` + TaskView string `json:"taskView"` + FollowUpStatus string `json:"followUpStatus"` + AppointmentStatus string `json:"appointmentStatus"` + OwnerUserID *int64 `json:"ownerUserId"` +} + +func (r SalesLeadListRequest) GetPage() int { + if r.Page <= 0 { + return 1 + } + return r.Page +} + +func (r SalesLeadListRequest) GetLimit() int { + if r.Limit <= 0 { + return 20 + } + if r.Limit > 100 { + return 100 + } + return r.Limit +} + +func (r SalesLeadListRequest) Offset() int { + return (r.GetPage() - 1) * r.GetLimit() +} + +type UpdateSalesLeadRequest struct { + ID int64 `json:"id"` + CustomerName string `json:"customerName"` + Phone string `json:"phone"` + WeChat string `json:"wechat"` + City string `json:"city"` + AddressHint string `json:"addressHint"` + BudgetMin int64 `json:"budgetMin"` + BudgetMax int64 `json:"budgetMax"` + InterestedProducts string `json:"interestedProducts"` + DemandSummary string `json:"demandSummary"` + IntentLevel string `json:"intentLevel"` + BuyingStage string `json:"buyingStage"` + AppointmentAt string `json:"appointmentAt"` + AppointmentTimeText string `json:"appointmentTimeText"` + AppointmentStore string `json:"appointmentStore"` + AppointmentPeople int `json:"appointmentPeople"` + AppointmentRemark string `json:"appointmentRemark"` + OwnerUserID int64 `json:"ownerUserId"` + Status string `json:"status"` + Remark string `json:"remark"` +} + +type AssignSalesLeadRequest struct { + ID int64 `json:"id"` + OwnerUserID int64 `json:"ownerUserId"` +} + +type UpdateSalesLeadStatusRequest struct { + ID int64 `json:"id"` + Status string `json:"status"` + Remark string `json:"remark"` +} + +type SyncSalesLeadToCRMRequest struct { + ID int64 `json:"id"` + Remark string `json:"remark"` +} + +type ClaimUnassignedSalesLeadsRequest struct { + Keyword string `json:"keyword"` + Status string `json:"status"` + Intent string `json:"intent"` + TaskView string `json:"taskView"` + FollowUpStatus string `json:"followUpStatus"` + AppointmentStatus string `json:"appointmentStatus"` + Limit int `json:"limit"` +} + +func (r ClaimUnassignedSalesLeadsRequest) ToListRequest() SalesLeadListRequest { + unassigned := int64(-1) + return SalesLeadListRequest{ + Page: 1, + Limit: r.GetLimit(), + Keyword: r.Keyword, + Status: r.Status, + Intent: r.Intent, + TaskView: r.TaskView, + FollowUpStatus: r.FollowUpStatus, + AppointmentStatus: r.AppointmentStatus, + OwnerUserID: &unassigned, + } +} + +func (r ClaimUnassignedSalesLeadsRequest) GetLimit() int { + if r.Limit <= 0 { + return 20 + } + if r.Limit > 100 { + return 100 + } + return r.Limit +} + +type CreateLeadFollowUpRequest struct { + LeadID int64 `json:"leadId"` + Content string `json:"content"` + NextAction string `json:"nextAction"` + NextFollowUpAt string `json:"nextFollowUpAt"` +} + +type SalesLeadFollowUpReminderRequest struct { + OwnerUserID *int64 `json:"ownerUserId"` + Limit int `json:"limit"` +} + +func (r SalesLeadFollowUpReminderRequest) GetLimit() int { + if r.Limit <= 0 { + return 10 + } + if r.Limit > 50 { + return 50 + } + return r.Limit +} + +type SalesLeadAppointmentSummaryRequest struct { + OwnerUserID *int64 `json:"ownerUserId"` + Days int `json:"days"` + Limit int `json:"limit"` +} + +func (r SalesLeadAppointmentSummaryRequest) GetDays() int { + if r.Days <= 0 { + return 7 + } + if r.Days > 30 { + return 30 + } + return r.Days +} + +func (r SalesLeadAppointmentSummaryRequest) GetLimit() int { + if r.Limit <= 0 { + return 8 + } + if r.Limit > 50 { + return 50 + } + return r.Limit +} diff --git a/internal/pkg/dto/response/conversation_response.go b/internal/pkg/dto/response/conversation_response.go index 697dbfdf..359540b7 100644 --- a/internal/pkg/dto/response/conversation_response.go +++ b/internal/pkg/dto/response/conversation_response.go @@ -51,3 +51,10 @@ type ConversationDetailResponse struct { ConversationResponse Participants []ConversationParticipantResponse `json:"participants,omitempty"` } + +type ConversationFollowUpAdviceResponse struct { + ConversationID int64 `json:"conversationId"` + LeadID int64 `json:"leadId,omitempty"` + Source string `json:"source"` + SalesLeadFollowUpAdviceResult +} diff --git a/internal/pkg/dto/response/dashboard_response.go b/internal/pkg/dto/response/dashboard_response.go index 9bba059c..4a8c832b 100644 --- a/internal/pkg/dto/response/dashboard_response.go +++ b/internal/pkg/dto/response/dashboard_response.go @@ -7,6 +7,7 @@ type DashboardOverviewResponse struct { ConversationStats DashboardSectionStatsResponse `json:"conversationStats"` AgentStats DashboardAgentStatsResponse `json:"agentStats"` AIStats DashboardAIStatsResponse `json:"aiStats"` + DigitalStoreStats DashboardDigitalStoreResponse `json:"digitalStoreStats"` Alerts []DashboardAlertResponse `json:"alerts"` QuickLinks []DashboardQuickLinkResponse `json:"quickLinks"` } @@ -67,6 +68,325 @@ type DashboardAIStatsResponse struct { TodayAIHandoffCount int64 `json:"todayAiHandoffCount"` } +type DashboardDigitalStoreResponse struct { + TodayConsultations int64 `json:"todayConsultations"` + TodayLeads int64 `json:"todayLeads"` + LeadConversionRate float64 `json:"leadConversionRate"` + TodayHighIntentLeads int64 `json:"todayHighIntentLeads"` + TodayAppointmentLeads int64 `json:"todayAppointmentLeads"` + TodayConvertedLeads int64 `json:"todayConvertedLeads"` + PendingFollowUpLeads int64 `json:"pendingFollowUpLeads"` + ActiveProducts int64 `json:"activeProducts"` + ActivePromotions int64 `json:"activePromotions"` + TodayHandoffs int64 `json:"todayHandoffs"` + TopLeadProducts []DashboardTopItemResponse `json:"topLeadProducts"` + Summary string `json:"summary"` +} + +type DashboardTopItemResponse struct { + Name string `json:"name"` + Count int64 `json:"count"` +} + +type DashboardDailyBusinessReportResponse struct { + ReportDate string `json:"reportDate"` + ConversationCount int64 `json:"conversationCount"` + AIReplyCount int64 `json:"aiReplyCount"` + HandoffCount int64 `json:"handoffCount"` + LeadCount int64 `json:"leadCount"` + LeadConversionRate float64 `json:"leadConversionRate"` + HighIntentCount int64 `json:"highIntentCount"` + AppointmentCount int64 `json:"appointmentCount"` + ConvertedCount int64 `json:"convertedCount"` + UnresolvedCount int64 `json:"unresolvedCount"` + UnassignedPriorityLeadCount int64 `json:"unassignedPriorityLeadCount"` + OverdueFollowUpCount int64 `json:"overdueFollowUpCount"` + TodayFollowUpCount int64 `json:"todayFollowUpCount"` + UnscheduledHotLeads int64 `json:"unscheduledHotLeads"` + OverdueAppointmentCount int64 `json:"overdueAppointmentCount"` + TodayAppointmentCount int64 `json:"todayAppointmentCount"` + UnscheduledAppointmentCount int64 `json:"unscheduledAppointmentCount"` + PendingAfterSalesTicketCount int64 `json:"pendingAfterSalesTicketCount"` + TodayAfterSalesTicketCount int64 `json:"todayAfterSalesTicketCount"` + TodayHandledAfterSalesTicketCount int64 `json:"todayHandledAfterSalesTicketCount"` + AIFeedbackCount int64 `json:"aiFeedbackCount"` + AIFeedbackLikeCount int64 `json:"aiFeedbackLikeCount"` + AIFeedbackNegativeCount int64 `json:"aiFeedbackNegativeCount"` + AIFeedbackNegativeRate float64 `json:"aiFeedbackNegativeRate"` + ActiveProductCount int64 `json:"activeProductCount"` + ActivePromotionCount int64 `json:"activePromotionCount"` + TopLeadProducts []DashboardTopItemResponse `json:"topLeadProducts"` + TopQuestions []DashboardTopItemResponse `json:"topQuestions"` + UnansweredQuestions []DashboardTopItemResponse `json:"unansweredQuestions"` + TopAIFeedbackReasons []DashboardTopItemResponse `json:"topAiFeedbackReasons"` + RecentNegativeAIFeedbacks []DashboardAIFeedbackResponse `json:"recentNegativeAiFeedbacks"` + PendingFAQDraftCount int64 `json:"pendingFaqDraftCount"` + PendingFAQDrafts []DashboardFAQDraftResponse `json:"pendingFaqDrafts"` + HighIntentLeads []DashboardReportLeadResponse `json:"highIntentLeads"` + PriorityFollowUps []DashboardReportLeadResponse `json:"priorityFollowUps"` + AfterSalesTickets []DashboardReportTicketResponse `json:"afterSalesTickets"` + Summary string `json:"summary"` + Highlights []string `json:"highlights"` + FollowUpSuggestions []string `json:"followUpSuggestions"` + KnowledgeSuggestions []string `json:"knowledgeSuggestions"` +} + +type DashboardDailyBusinessReportPushResponse struct { + ReportDate string `json:"reportDate"` + GeneratedAt string `json:"generatedAt"` + WebhookEnabled bool `json:"webhookEnabled"` + DailyEnabled bool `json:"dailyEnabled"` + Sent bool `json:"sent"` + Title string `json:"title"` + Message string `json:"message"` + WebhookEventType string `json:"webhookEventType"` +} + +type DashboardAIQualityReportResponse struct { + Range string `json:"range"` + GeneratedAt string `json:"generatedAt"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + RetrieveTotal int64 `json:"retrieveTotal"` + RetrieveHitTotal int64 `json:"retrieveHitTotal"` + RetrieveHitRate float64 `json:"retrieveHitRate"` + NoAnswerCount int64 `json:"noAnswerCount"` + FallbackCount int64 `json:"fallbackCount"` + BlockedCount int64 `json:"blockedCount"` + RiskAnswerCount int64 `json:"riskAnswerCount"` + NegativeFeedbackCount int64 `json:"negativeFeedbackCount"` + FeedbackCount int64 `json:"feedbackCount"` + NegativeFeedbackRate float64 `json:"negativeFeedbackRate"` + PendingFAQDraftCount int64 `json:"pendingFaqDraftCount"` + TodoTotal int64 `json:"todoTotal"` + Todos []DashboardAIQualityTodoItem `json:"todos"` + TopQuestions []DashboardTopItemResponse `json:"topQuestions"` + UnansweredQuestions []DashboardTopItemResponse `json:"unansweredQuestions"` + TopNegativeReasons []DashboardTopItemResponse `json:"topNegativeReasons"` + PendingQuestionGroups []DashboardPendingQuestionGroup `json:"pendingQuestionGroups"` + RecentNegativeFeedbacks []DashboardAIFeedbackResponse `json:"recentNegativeFeedbacks"` + PendingFAQDrafts []DashboardFAQDraftResponse `json:"pendingFaqDrafts"` + RecentRiskAnswerSamples []DashboardAIRiskAnswerItem `json:"recentRiskAnswerSamples"` + KnowledgeSuggestions []string `json:"knowledgeSuggestions"` +} + +type DashboardSalesFunnelReportResponse struct { + Range string `json:"range"` + GeneratedAt string `json:"generatedAt"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + ConversationTotal int64 `json:"conversationTotal"` + LeadTotal int64 `json:"leadTotal"` + LeadConversionRate float64 `json:"leadConversionRate"` + ClosedConversionRate float64 `json:"closedConversionRate"` + AppointmentTotal int64 `json:"appointmentTotal"` + VisitedTotal int64 `json:"visitedTotal"` + ConvertedTotal int64 `json:"convertedTotal"` + InvalidTotal int64 `json:"invalidTotal"` + UnassignedTotal int64 `json:"unassignedTotal"` + OverdueFollowUpTotal int64 `json:"overdueFollowUpTotal"` + InvalidReasons []DashboardTopItemResponse `json:"invalidReasons"` + Steps []DashboardSalesFunnelStep `json:"steps"` + AdvisorStats []DashboardAdvisorEfficiency `json:"advisorStats"` + Suggestions []string `json:"suggestions"` +} + +type DashboardSalesFunnelStep struct { + Key string `json:"key"` + Label string `json:"label"` + Count int64 `json:"count"` + Rate float64 `json:"rate"` + DropOffCount int64 `json:"dropOffCount"` + DropOffRate float64 `json:"dropOffRate"` + ActionHref string `json:"actionHref,omitempty"` +} + +type DashboardBusinessTrendReportResponse struct { + Range string `json:"range"` + GeneratedAt string `json:"generatedAt"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + ConversationTotal int64 `json:"conversationTotal"` + LeadTotal int64 `json:"leadTotal"` + LeadConversionRate float64 `json:"leadConversionRate"` + HighIntentTotal int64 `json:"highIntentTotal"` + AppointmentTotal int64 `json:"appointmentTotal"` + VisitedTotal int64 `json:"visitedTotal"` + ConvertedTotal int64 `json:"convertedTotal"` + HandoffTotal int64 `json:"handoffTotal"` + NegativeFeedbackTotal int64 `json:"negativeFeedbackTotal"` + PendingFAQDraftCount int64 `json:"pendingFaqDraftCount"` + Series []DashboardBusinessTrendItem `json:"series"` + TopProducts []DashboardTopItemResponse `json:"topProducts"` + TopChannels []DashboardTopItemResponse `json:"topChannels"` + TopQuestions []DashboardTopItemResponse `json:"topQuestions"` + TopUnansweredQuestions []DashboardTopItemResponse `json:"topUnansweredQuestions"` + TopNegativeReasons []DashboardTopItemResponse `json:"topNegativeReasons"` + AdvisorStats []DashboardAdvisorEfficiency `json:"advisorStats"` + Suggestions []string `json:"suggestions"` + ReportMarkdown string `json:"reportMarkdown"` +} + +type DashboardBusinessTrendItem struct { + Date string `json:"date"` + ConversationCount int64 `json:"conversationCount"` + LeadCount int64 `json:"leadCount"` + HighIntentCount int64 `json:"highIntentCount"` + AppointmentCount int64 `json:"appointmentCount"` + VisitedCount int64 `json:"visitedCount"` + ConvertedCount int64 `json:"convertedCount"` + HandoffCount int64 `json:"handoffCount"` + NegativeFeedbackCount int64 `json:"negativeFeedbackCount"` +} + +type DashboardABTestReportResponse struct { + Range string `json:"range"` + GeneratedAt string `json:"generatedAt"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + VariantTotal int64 `json:"variantTotal"` + LeadTotal int64 `json:"leadTotal"` + FeedbackTotal int64 `json:"feedbackTotal"` + NegativeFeedbackTotal int64 `json:"negativeFeedbackTotal"` + NegativeFeedbackRate float64 `json:"negativeFeedbackRate"` + Variants []DashboardABTestVariantResult `json:"variants"` + Suggestions []string `json:"suggestions"` +} + +type DashboardABTestVariantResult struct { + VariantCode string `json:"variantCode"` + VariantName string `json:"variantName"` + LeadCount int64 `json:"leadCount"` + HighIntentCount int64 `json:"highIntentCount"` + HighIntentRate float64 `json:"highIntentRate"` + AppointmentCount int64 `json:"appointmentCount"` + AppointmentRate float64 `json:"appointmentRate"` + VisitedCount int64 `json:"visitedCount"` + VisitRate float64 `json:"visitRate"` + ConvertedCount int64 `json:"convertedCount"` + ConversionRate float64 `json:"conversionRate"` + InvalidCount int64 `json:"invalidCount"` + InvalidRate float64 `json:"invalidRate"` + QualityRiskLevel string `json:"qualityRiskLevel"` + QualityRiskReason string `json:"qualityRiskReason"` + TopProduct string `json:"topProduct"` + RecommendedAction string `json:"recommendedAction"` +} + +type DashboardAdvisorEfficiency struct { + OwnerUserID int64 `json:"ownerUserId"` + OwnerUserName string `json:"ownerUserName"` + AssignedLeadCount int64 `json:"assignedLeadCount"` + FollowUpCount int64 `json:"followUpCount"` + OverdueFollowUpCount int64 `json:"overdueFollowUpCount"` + TodayFollowUpCount int64 `json:"todayFollowUpCount"` + ConvertedLeadCount int64 `json:"convertedLeadCount"` + InvalidLeadCount int64 `json:"invalidLeadCount"` + ConversionRate float64 `json:"conversionRate"` + InvalidRate float64 `json:"invalidRate"` + AverageFirstFollowUpMinutes int64 `json:"averageFirstFollowUpMinutes"` + InvalidReasons []DashboardTopItemResponse `json:"invalidReasons"` +} + +type DashboardAIQualityTodoItem struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + Count int64 `json:"count"` + Level string `json:"level"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` +} + +type DashboardPendingQuestionGroup struct { + Question string `json:"question"` + Count int64 `json:"count"` + NoAnswerCount int64 `json:"noAnswerCount"` + FallbackCount int64 `json:"fallbackCount"` + BlockedCount int64 `json:"blockedCount"` + NegativeFeedbackCount int64 `json:"negativeFeedbackCount"` + LatestRetrieveLogID int64 `json:"latestRetrieveLogId"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + LatestAt string `json:"latestAt"` + ActionHref string `json:"actionHref"` + ActionLabel string `json:"actionLabel"` +} + +type DashboardAIRiskAnswerItem struct { + ID int64 `json:"id"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Question string `json:"question"` + AnswerStatus int `json:"answerStatus"` + AnswerStatusName string `json:"answerStatusName"` + HitCount int `json:"hitCount"` + TopScore string `json:"topScore"` + ModelName string `json:"modelName"` + CreatedAt string `json:"createdAt"` + ActionHref string `json:"actionHref"` +} + +type DashboardReportLeadResponse struct { + ID int64 `json:"id"` + CustomerName string `json:"customerName"` + Phone string `json:"phone"` + WeChat string `json:"wechat"` + City string `json:"city"` + InterestedProducts string `json:"interestedProducts"` + DemandSummary string `json:"demandSummary"` + BuyingStage string `json:"buyingStage"` + AppointmentAt string `json:"appointmentAt,omitempty"` + AppointmentTimeText string `json:"appointmentTimeText"` + AppointmentStore string `json:"appointmentStore"` + AppointmentPeople int `json:"appointmentPeople"` + Status string `json:"status"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerUserName string `json:"ownerUserName,omitempty"` + NextFollowUpAt string `json:"nextFollowUpAt,omitempty"` + FollowUpState string `json:"followUpState,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type DashboardReportTicketResponse struct { + ID int64 `json:"id"` + TicketNo string `json:"ticketNo"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + CurrentAssigneeID int64 `json:"currentAssigneeId"` + CurrentAssigneeName string `json:"currentAssigneeName,omitempty"` + ConversationID int64 `json:"conversationId"` + CustomerID int64 `json:"customerId"` + LatestProgress string `json:"latestProgress,omitempty"` + LatestProgressAt string `json:"latestProgressAt,omitempty"` + HandledAt string `json:"handledAt,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type DashboardAIFeedbackResponse struct { + ID int64 `json:"id"` + RetrieveLogID int64 `json:"retrieveLogId"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + FeedbackType int `json:"feedbackType"` + FeedbackTypeName string `json:"feedbackTypeName"` + FeedbackReason string `json:"feedbackReason"` + Question string `json:"question"` + AnswerStatus int `json:"answerStatus"` + AnswerStatusName string `json:"answerStatusName"` + ModelName string `json:"modelName"` + CreatedAt string `json:"createdAt"` +} + +type DashboardFAQDraftResponse struct { + ID int64 `json:"id"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Question string `json:"question"` + Answer string `json:"answer"` + Remark string `json:"remark"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + type DashboardAlertResponse struct { ID string `json:"id"` Level string `json:"level"` diff --git a/internal/pkg/dto/response/digital_store_response.go b/internal/pkg/dto/response/digital_store_response.go new file mode 100644 index 00000000..4593f942 --- /dev/null +++ b/internal/pkg/dto/response/digital_store_response.go @@ -0,0 +1,375 @@ +package response + +type DigitalStoreProfileResponse struct { + BrandName string `json:"brandName"` + Industry string `json:"industry"` + StoreName string `json:"storeName"` + StoreAddress string `json:"storeAddress"` + BusinessHours string `json:"businessHours"` + ContactPhone string `json:"contactPhone"` + ServiceWeChat string `json:"serviceWechat"` + EnterpriseWebhookURL string `json:"enterpriseWebhookUrl"` + AIManagerName string `json:"aiManagerName"` + AIPersona string `json:"aiPersona"` + ReplyStyle string `json:"replyStyle"` + ForbiddenClaims string `json:"forbiddenClaims"` + HandoffPolicy string `json:"handoffPolicy"` + AppointmentPolicy string `json:"appointmentPolicy"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + KnowledgeFAQID int64 `json:"knowledgeFAQId"` + TemplateCode string `json:"templateCode"` + TemplateVersion string `json:"templateVersion"` + TemplateAppliedAt string `json:"templateAppliedAt,omitempty"` + Initialized bool `json:"initialized"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type DigitalStoreSetupStatusResponse struct { + ProfileInitialized bool `json:"profileInitialized"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + KnowledgeFAQID int64 `json:"knowledgeFAQId"` + ProductTotal int64 `json:"productTotal"` + PromotionTotal int64 `json:"promotionTotal"` + ProductKnowledgeSyncedTotal int64 `json:"productKnowledgeSyncedTotal"` + ProductKnowledgeUnsyncedTotal int64 `json:"productKnowledgeUnsyncedTotal"` + ProductKnowledgeFailedTotal int64 `json:"productKnowledgeFailedTotal"` + PromotionKnowledgeSyncedTotal int64 `json:"promotionKnowledgeSyncedTotal"` + PromotionKnowledgeUnsyncedTotal int64 `json:"promotionKnowledgeUnsyncedTotal"` + PromotionKnowledgeFailedTotal int64 `json:"promotionKnowledgeFailedTotal"` + LLMConfigID int64 `json:"llmConfigId"` + LLMConfigName string `json:"llmConfigName"` + EmbeddingConfigID int64 `json:"embeddingConfigId"` + EmbeddingConfigName string `json:"embeddingConfigName"` + AgentID int64 `json:"agentId"` + AgentName string `json:"agentName"` + WorkflowPublished bool `json:"workflowPublished"` + WebChannelID int64 `json:"webChannelId"` + WebChannelCode string `json:"webChannelCode"` + WebChannelName string `json:"webChannelName"` + WebEntry DigitalStoreWebEntryResponse `json:"webEntry"` + HumanHandoff DigitalStoreHumanHandoffResponse `json:"humanHandoff"` + ModelHealthChecks []DigitalStoreHealthCheckResponse `json:"modelHealthChecks"` + Ready bool `json:"ready"` + MissingSteps []string `json:"missingSteps"` +} + +type DigitalStoreHumanHandoffResponse struct { + Ready bool `json:"ready"` + AgentTeamIDs []int64 `json:"agentTeamIds"` + ActiveTeamIDs []int64 `json:"activeTeamIds"` + AgentProfileTotal int64 `json:"agentProfileTotal"` + AutoAssignProfiles int64 `json:"autoAssignProfiles"` + EligibleProfiles int `json:"eligibleProfiles"` + CandidateProfiles int `json:"candidateProfiles"` + Message string `json:"message"` +} + +type DigitalStoreWebEntryResponse struct { + ChannelID int64 `json:"channelId"` + ChannelCode string `json:"channelCode"` + ChannelName string `json:"channelName"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` + ChatURL string `json:"chatUrl"` + EmbedSnippet string `json:"embedSnippet"` +} + +type DigitalStoreDeliveryReportItem struct { + Label string `json:"label"` + Status string `json:"status"` + Value string `json:"value"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` +} + +type DigitalStoreAcceptanceItem struct { + Code string `json:"code"` + Title string `json:"title"` + CustomerAsk string `json:"customerAsk"` + Expectation string `json:"expectation"` + ConsoleCheck string `json:"consoleCheck"` + Blocking bool `json:"blocking"` +} + +type DigitalStoreNotificationStatusResponse struct { + Enabled bool `json:"enabled"` + Configured bool `json:"configured"` + Format string `json:"format"` + HasSecret bool `json:"hasSecret"` + ProfileWebhookURLSet bool `json:"profileWebhookUrlSet"` + Status string `json:"status"` + Message string `json:"message"` +} + +type DigitalStoreSecurityCheckResponse struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` +} + +type DigitalStoreHealthCheckResponse struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` +} + +type DigitalStoreWebhookTestResponse struct { + DigitalStoreNotificationStatusResponse + Sent bool `json:"sent"` + TestedAt string `json:"testedAt"` + SentTotal int `json:"sentTotal"` + FailedTotal int `json:"failedTotal"` + Scenarios []DigitalStoreWebhookTestScenarioResponse `json:"scenarios,omitempty"` +} + +type DigitalStoreWebhookTestScenarioResponse struct { + Key string `json:"key"` + EventType string `json:"eventType"` + Title string `json:"title"` + Sent bool `json:"sent"` + Message string `json:"message"` +} + +type DigitalStoreDemoDataCleanupResponse struct { + CleanedAt string `json:"cleanedAt"` + Message string `json:"message"` + Deleted map[string]int64 `json:"deleted"` +} + +type DigitalStoreMaintenanceStatusResponse struct { + CheckedAt string `json:"checkedAt"` + Status string `json:"status"` + BackupRoot string `json:"backupRoot"` + BackupCommand string `json:"backupCommand"` + RestoreDryRunCommand string `json:"restoreDryRunCommand"` + UpgradeCommands []string `json:"upgradeCommands"` + UpgradeRunbook string `json:"upgradeRunbook"` + LatestBackup *DigitalStoreBackupSnapshotResponse `json:"latestBackup,omitempty"` + Warnings []DigitalStoreMaintenanceWarningResponse `json:"warnings"` +} + +type DigitalStoreBackupSnapshotResponse struct { + Path string `json:"path"` + Timestamp string `json:"timestamp"` + CreatedAt string `json:"createdAt"` + ProjectDir string `json:"projectDir"` + ComposeFile string `json:"composeFile"` + HasManifest bool `json:"hasManifest"` + HasMySQLDump bool `json:"hasMysqlDump"` + HasDataArchive bool `json:"hasDataArchive"` + HasDockerConfigArchive bool `json:"hasDockerConfigArchive"` + HasConfigSnapshot bool `json:"hasConfigSnapshot"` + SizeBytes int64 `json:"sizeBytes"` +} + +type DigitalStoreMaintenanceWarningResponse struct { + Key string `json:"key"` + Label string `json:"label"` + Message string `json:"message"` +} + +type DigitalStoreDeliveryReportResponse struct { + GeneratedAt string `json:"generatedAt"` + BrandName string `json:"brandName"` + StoreName string `json:"storeName"` + Ready bool `json:"ready"` + DashboardURL string `json:"dashboardUrl"` + ChatURL string `json:"chatUrl"` + EmbedSnippet string `json:"embedSnippet"` + WebEntry DigitalStoreWebEntryResponse `json:"webEntry"` + HumanHandoff DigitalStoreHumanHandoffResponse `json:"humanHandoff"` + AcceptanceCommand string `json:"acceptanceCommand"` + AcceptanceItems []DigitalStoreAcceptanceItem `json:"acceptanceItems"` + NotificationStatus DigitalStoreNotificationStatusResponse `json:"notificationStatus"` + SecurityChecks []DigitalStoreSecurityCheckResponse `json:"securityChecks"` + ModelHealthChecks []DigitalStoreHealthCheckResponse `json:"modelHealthChecks"` + Items []DigitalStoreDeliveryReportItem `json:"items"` + MissingSteps []string `json:"missingSteps"` + LatestRecord *DigitalStoreDeliveryRecordResponse `json:"latestRecord,omitempty"` + Markdown string `json:"markdown"` + AcceptanceRunbook string `json:"acceptanceRunbook"` +} + +type DigitalStoreDeliveryRecordResponse struct { + ID int64 `json:"id"` + BrandName string `json:"brandName"` + StoreName string `json:"storeName"` + Ready bool `json:"ready"` + AcceptanceStatus string `json:"acceptanceStatus"` + AcceptanceSummary string `json:"acceptanceSummary"` + AcceptanceCommand string `json:"acceptanceCommand"` + ScenarioTotal int `json:"scenarioTotal"` + PassedTotal int `json:"passedTotal"` + FailedTotal int `json:"failedTotal"` + AcceptanceStartedAt string `json:"acceptanceStartedAt,omitempty"` + AcceptanceFinishedAt string `json:"acceptanceFinishedAt,omitempty"` + DashboardURL string `json:"dashboardUrl"` + ChatURL string `json:"chatUrl"` + WebChannelCode string `json:"webChannelCode"` + CreatedAt string `json:"createdAt"` + CreateUserName string `json:"createUserName"` + AcceptanceResults []DigitalStoreAcceptanceScenarioResultResponse `json:"acceptanceResults,omitempty"` +} + +type DigitalStoreAcceptanceScenarioResultResponse struct { + Code string `json:"code"` + Title string `json:"title"` + Passed bool `json:"passed"` + Reason string `json:"reason"` + FailureType string `json:"failureType"` + Detail string `json:"detail"` + Suggestion string `json:"suggestion"` + ConversationID int64 `json:"conversationId"` + ConversationURL string `json:"conversationUrl"` + Reply string `json:"reply"` + ExpectedKeywords []string `json:"expectedKeywords"` + MatchedKeywords []string `json:"matchedKeywords"` + MissingKeywords []string `json:"missingKeywords"` + BannedKeywords []string `json:"bannedKeywords"` + MatchedBanned string `json:"matchedBanned"` +} + +type DigitalStoreTemplateResponse struct { + Code string `json:"code"` + Name string `json:"name"` + Industry string `json:"industry"` + Version string `json:"version"` + Description string `json:"description"` +} + +type DigitalStoreTemplateExportResponse struct { + SchemaVersion string `json:"schemaVersion"` + ExportedAt string `json:"exportedAt"` + Template DigitalStoreTemplateResponse `json:"template"` + Profile DigitalStoreProfileResponse `json:"profile"` + Products []DigitalStoreTemplateProductResponse `json:"products"` + Promotions []DigitalStoreTemplatePromotionResponse `json:"promotions"` + RiskRules []DigitalStoreIndustryRiskRuleResponse `json:"riskRules"` + AcceptanceItems []DigitalStoreAcceptanceItem `json:"acceptanceItems"` +} + +type DigitalStoreTemplatePreviewResponse struct { + Template DigitalStoreTemplateResponse `json:"template"` + Profile DigitalStoreProfileResponse `json:"profile"` + ProfileAction string `json:"profileAction"` + ProductCreateTotal int `json:"productCreateTotal"` + ProductUpdateTotal int `json:"productUpdateTotal"` + PromotionCreateTotal int `json:"promotionCreateTotal"` + PromotionUpdateTotal int `json:"promotionUpdateTotal"` + Products []DigitalStoreTemplatePreviewItem `json:"products"` + Promotions []DigitalStoreTemplatePreviewItem `json:"promotions"` + RiskRules []DigitalStoreIndustryRiskRuleResponse `json:"riskRules"` + AcceptanceItems []DigitalStoreAcceptanceItem `json:"acceptanceItems"` + Warnings []DigitalStoreTemplatePreviewWarning `json:"warnings"` +} + +type DigitalStoreIndustryRiskRuleResponse struct { + Key string `json:"key"` + Label string `json:"label"` + ForbiddenClaims []string `json:"forbiddenClaims"` + HandoffTriggers []string `json:"handoffTriggers"` +} + +type DigitalStoreTemplatePreviewItem struct { + Name string `json:"name"` + Action string `json:"action"` + ExistingID int64 `json:"existingId,omitempty"` + Reason string `json:"reason"` +} + +type DigitalStoreTemplatePreviewWarning struct { + Key string `json:"key"` + Message string `json:"message"` +} + +type DigitalStoreKnowledgeAssistantResponse struct { + GeneratedAt string `json:"generatedAt"` + Industry string `json:"industry"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + CoveredTotal int `json:"coveredTotal"` + MissingTotal int `json:"missingTotal"` + Items []DigitalStoreKnowledgeAssistantItem `json:"items"` +} + +type DigitalStoreKnowledgeAssistantItem struct { + Key string `json:"key"` + Question string `json:"question"` + Reason string `json:"reason"` + Required bool `json:"required"` + Covered bool `json:"covered"` + MatchedFAQID int64 `json:"matchedFaqId,omitempty"` + Keywords []string `json:"keywords"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` +} + +type DigitalStoreTemplateEffectResponse struct { + GeneratedAt string `json:"generatedAt"` + TemplateCode string `json:"templateCode"` + TemplateVersion string `json:"templateVersion"` + TemplateAppliedAt string `json:"templateAppliedAt,omitempty"` + Industry string `json:"industry"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + Days int `json:"days"` + RetrieveTotal int64 `json:"retrieveTotal"` + MissingQuestionTotal int64 `json:"missingQuestionTotal"` + NegativeFeedbackTotal int64 `json:"negativeFeedbackTotal"` + MissingQuestions []DigitalStoreTemplateEffectItem `json:"missingQuestions"` + NegativeFeedbacks []DigitalStoreTemplateEffectItem `json:"negativeFeedbacks"` + Suggestions []string `json:"suggestions"` + ImprovementMarkdown string `json:"improvementMarkdown"` +} + +type DigitalStoreTemplateEffectItem struct { + Question string `json:"question"` + Count int64 `json:"count"` + LatestAt string `json:"latestAt,omitempty"` + FeedbackReason string `json:"feedbackReason,omitempty"` + FeedbackTypeName string `json:"feedbackTypeName,omitempty"` + AnswerStatusName string `json:"answerStatusName,omitempty"` + ActionHref string `json:"actionHref,omitempty"` + ActionLabel string `json:"actionLabel,omitempty"` + CreateFAQActionHref string `json:"createFaqActionHref,omitempty"` +} + +type DigitalStoreTemplateProductResponse struct { + Name string `json:"name"` + Category string `json:"category"` + PriceMin int64 `json:"priceMin"` + PriceMax int64 `json:"priceMax"` + SellingPoints string `json:"sellingPoints"` + SuitablePeople string `json:"suitablePeople"` + UnsuitablePeople string `json:"unsuitablePeople"` + Scenarios string `json:"scenarios"` + Specs string `json:"specs"` + IndustryAttributes string `json:"industryAttributes"` + ImageURL string `json:"imageUrl"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +type DigitalStoreTemplatePromotionResponse struct { + Name string `json:"name"` + PromotionType string `json:"promotionType"` + Description string `json:"description"` + ApplicableProducts string `json:"applicableProducts"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + DiscountRule string `json:"discountRule"` + StoreBenefit string `json:"storeBenefit"` + AppointmentBenefit string `json:"appointmentBenefit"` + ScriptSuggestion string `json:"scriptSuggestion"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` +} diff --git a/internal/pkg/dto/response/knowledge_response.go b/internal/pkg/dto/response/knowledge_response.go index 6f83aaf7..f13621bc 100644 --- a/internal/pkg/dto/response/knowledge_response.go +++ b/internal/pkg/dto/response/knowledge_response.go @@ -189,39 +189,44 @@ type KnowledgeCitation struct { } 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"` + 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"` + FeedbackCount int64 `json:"feedbackCount"` + NegativeFeedbackCount int64 `json:"negativeFeedbackCount"` + LatestFeedbackType int `json:"latestFeedbackType"` + LatestFeedbackTypeName string `json:"latestFeedbackTypeName"` + LatestFeedbackReason string `json:"latestFeedbackReason"` + CreatedAt time.Time `json:"createdAt"` } type KnowledgeRetrieveHitResponse struct { @@ -249,8 +254,24 @@ type KnowledgeRetrieveHitResponse struct { } type KnowledgeRetrieveLogDetailResponse struct { - Log KnowledgeRetrieveLogResponse `json:"log"` - Hits []KnowledgeRetrieveHitResponse `json:"hits"` + Log KnowledgeRetrieveLogResponse `json:"log"` + Hits []KnowledgeRetrieveHitResponse `json:"hits"` + Feedbacks []KnowledgeFeedbackResponse `json:"feedbacks"` +} + +type KnowledgeFAQDraftBatchCreateResponse struct { + TotalCandidates int64 `json:"totalCandidates"` + CreatedCount int64 `json:"createdCount"` + ReusedCount int64 `json:"reusedCount"` + SkippedCount int64 `json:"skippedCount"` + DraftIDs []int64 `json:"draftIds"` + Skipped []KnowledgeFAQDraftBatchSkipReason `json:"skipped"` +} + +type KnowledgeFAQDraftBatchSkipReason struct { + RetrieveLogID int64 `json:"retrieveLogId"` + Question string `json:"question"` + Reason string `json:"reason"` } type KnowledgeFeedbackResponse struct { diff --git a/internal/pkg/dto/response/product_response.go b/internal/pkg/dto/response/product_response.go new file mode 100644 index 00000000..d3e3eb11 --- /dev/null +++ b/internal/pkg/dto/response/product_response.go @@ -0,0 +1,39 @@ +package response + +import "agent-desk/internal/pkg/enums" + +type ProductResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + PriceMin int64 `json:"priceMin"` + PriceMax int64 `json:"priceMax"` + SellingPoints string `json:"sellingPoints"` + SuitablePeople string `json:"suitablePeople"` + UnsuitablePeople string `json:"unsuitablePeople"` + Scenarios string `json:"scenarios"` + Specs string `json:"specs"` + IndustryAttributes string `json:"industryAttributes"` + ImageURL string `json:"imageUrl"` + Priority int `json:"priority"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + KnowledgeFAQID int64 `json:"knowledgeFAQId"` + Status enums.Status `json:"status"` + Remark string `json:"remark"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type ProductImportResultResponse struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Errors []ProductImportRowResponse `json:"errors"` +} + +type ProductImportRowResponse struct { + Row int `json:"row"` + Message string `json:"message"` +} diff --git a/internal/pkg/dto/response/promotion_response.go b/internal/pkg/dto/response/promotion_response.go new file mode 100644 index 00000000..b40144e1 --- /dev/null +++ b/internal/pkg/dto/response/promotion_response.go @@ -0,0 +1,38 @@ +package response + +import "agent-desk/internal/pkg/enums" + +type PromotionResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + PromotionType string `json:"promotionType"` + Description string `json:"description"` + ApplicableProducts string `json:"applicableProducts"` + StartAt string `json:"startAt,omitempty"` + EndAt string `json:"endAt,omitempty"` + DiscountRule string `json:"discountRule"` + StoreBenefit string `json:"storeBenefit"` + AppointmentBenefit string `json:"appointmentBenefit"` + ScriptSuggestion string `json:"scriptSuggestion"` + Priority int `json:"priority"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + KnowledgeFAQID int64 `json:"knowledgeFAQId"` + Status enums.Status `json:"status"` + Remark string `json:"remark"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type PromotionImportResultResponse struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Errors []PromotionImportRowResponse `json:"errors"` +} + +type PromotionImportRowResponse struct { + Row int `json:"row"` + Message string `json:"message"` +} diff --git a/internal/pkg/dto/response/sales_lead_response.go b/internal/pkg/dto/response/sales_lead_response.go new file mode 100644 index 00000000..6a06574c --- /dev/null +++ b/internal/pkg/dto/response/sales_lead_response.go @@ -0,0 +1,149 @@ +package response + +import "agent-desk/internal/pkg/enums" + +type SalesLeadResponse struct { + ID int64 `json:"id"` + CustomerID int64 `json:"customerId"` + ConversationID int64 `json:"conversationId"` + CustomerName string `json:"customerName"` + Phone string `json:"phone"` + WeChat string `json:"wechat"` + City string `json:"city"` + AddressHint string `json:"addressHint"` + BudgetMin int64 `json:"budgetMin"` + BudgetMax int64 `json:"budgetMax"` + InterestedProducts string `json:"interestedProducts"` + DemandSummary string `json:"demandSummary"` + IntentLevel enums.SalesLeadIntent `json:"intentLevel"` + BuyingStage enums.SalesLeadStage `json:"buyingStage"` + AppointmentAt string `json:"appointmentAt,omitempty"` + AppointmentTimeText string `json:"appointmentTimeText"` + AppointmentStore string `json:"appointmentStore"` + AppointmentPeople int `json:"appointmentPeople"` + AppointmentRemark string `json:"appointmentRemark"` + SourceChannel string `json:"sourceChannel"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerUserName string `json:"ownerUserName,omitempty"` + Status enums.SalesLeadStatus `json:"status"` + NextFollowUpAt string `json:"nextFollowUpAt,omitempty"` + LastMessageID int64 `json:"lastMessageId"` + LastMessageSummary string `json:"lastMessageSummary"` + LastCustomerMessage string `json:"lastCustomerMessage"` + MergeKey string `json:"mergeKey"` + MergeReason string `json:"mergeReason"` + MergedAt string `json:"mergedAt,omitempty"` + Remark string `json:"remark"` + AutoTags []string `json:"autoTags"` + AutoTagDetails []SalesLeadAutoTag `json:"autoTagDetails"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Customer *CustomerResponse `json:"customer,omitempty"` +} + +type SalesLeadAutoTag struct { + Label string `json:"label"` + Level string `json:"level"` + Reason string `json:"reason"` + ActionLabel string `json:"actionLabel"` + ActionURL string `json:"actionUrl,omitempty"` +} + +type LeadFollowUpResponse struct { + ID int64 `json:"id"` + LeadID int64 `json:"leadId"` + OperatorID int64 `json:"operatorId"` + OperatorName string `json:"operatorName"` + Content string `json:"content"` + NextAction string `json:"nextAction"` + NextFollowUpAt string `json:"nextFollowUpAt,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +type SalesLeadDetailResponse struct { + Lead SalesLeadResponse `json:"lead"` + FollowUps []LeadFollowUpResponse `json:"followUps,omitempty"` + FollowUpAdvice SalesLeadFollowUpAdviceResult `json:"followUpAdvice"` +} + +type SalesLeadFollowUpAdviceResult struct { + CustomerSummary string `json:"customerSummary"` + NextAction string `json:"nextAction"` + Script string `json:"script"` + CopyText string `json:"copyText"` + RiskHints []string `json:"riskHints"` +} + +type ClaimUnassignedSalesLeadsResponse struct { + ClaimedCount int64 `json:"claimedCount"` + LeadIDs []int64 `json:"leadIds"` + Message string `json:"message"` +} + +type SalesLeadCRMSyncResponse struct { + LeadID int64 `json:"leadId"` + GeneratedAt string `json:"generatedAt"` + WebhookEnabled bool `json:"webhookEnabled"` + Sent bool `json:"sent"` + Title string `json:"title"` + Message string `json:"message"` + WebhookEventType string `json:"webhookEventType"` +} + +type SalesLeadFollowUpReminderLeadResponse struct { + ID int64 `json:"id"` + CustomerName string `json:"customerName"` + Phone string `json:"phone"` + WeChat string `json:"wechat"` + IntentLevel enums.SalesLeadIntent `json:"intentLevel"` + Status enums.SalesLeadStatus `json:"status"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerUserName string `json:"ownerUserName,omitempty"` + NextFollowUpAt string `json:"nextFollowUpAt,omitempty"` + FollowUpState string `json:"followUpState"` + DemandSummary string `json:"demandSummary"` + ActionURL string `json:"actionUrl"` +} + +type SalesLeadFollowUpReminderSummaryResponse struct { + GeneratedAt string `json:"generatedAt"` + OverdueCount int64 `json:"overdueCount"` + TodayCount int64 `json:"todayCount"` + DueCount int64 `json:"dueCount"` + UnassignedDueCount int64 `json:"unassignedDueCount"` + MissingScheduleCount int64 `json:"missingScheduleCount"` + PreviewLeads []SalesLeadFollowUpReminderLeadResponse `json:"previewLeads"` + Message string `json:"message"` + NotificationSent bool `json:"notificationSent"` +} + +type SalesLeadAppointmentItemResponse struct { + ID int64 `json:"id"` + CustomerName string `json:"customerName"` + Phone string `json:"phone"` + WeChat string `json:"wechat"` + IntentLevel enums.SalesLeadIntent `json:"intentLevel"` + Status enums.SalesLeadStatus `json:"status"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerUserName string `json:"ownerUserName,omitempty"` + AppointmentAt string `json:"appointmentAt,omitempty"` + AppointmentTimeText string `json:"appointmentTimeText"` + AppointmentStore string `json:"appointmentStore"` + AppointmentPeople int `json:"appointmentPeople"` + DemandSummary string `json:"demandSummary"` + AppointmentState string `json:"appointmentState"` + ActionURL string `json:"actionUrl"` +} + +type SalesLeadAppointmentSummaryResponse struct { + GeneratedAt string `json:"generatedAt"` + Days int `json:"days"` + OverdueCount int64 `json:"overdueCount"` + TodayCount int64 `json:"todayCount"` + UpcomingCount int64 `json:"upcomingCount"` + UnscheduledCount int64 `json:"unscheduledCount"` + UnassignedCount int64 `json:"unassignedCount"` + PreviewAppointments []SalesLeadAppointmentItemResponse `json:"previewAppointments"` + Message string `json:"message"` + NotificationSent bool `json:"notificationSent"` +} diff --git a/internal/pkg/enums/sales_lead.go b/internal/pkg/enums/sales_lead.go new file mode 100644 index 00000000..62f3cc65 --- /dev/null +++ b/internal/pkg/enums/sales_lead.go @@ -0,0 +1,41 @@ +package enums + +type SalesLeadStatus string + +const ( + SalesLeadStatusNew SalesLeadStatus = "new" + SalesLeadStatusFollowing SalesLeadStatus = "following" + SalesLeadStatusVisited SalesLeadStatus = "visited" + SalesLeadStatusConverted SalesLeadStatus = "converted" + SalesLeadStatusInvalid SalesLeadStatus = "invalid" + SalesLeadStatusClosed SalesLeadStatus = "closed" +) + +type SalesLeadIntent string + +const ( + SalesLeadIntentUnknown SalesLeadIntent = "unknown" + SalesLeadIntentLow SalesLeadIntent = "low" + SalesLeadIntentMedium SalesLeadIntent = "medium" + SalesLeadIntentHigh SalesLeadIntent = "high" +) + +type SalesLeadStage string + +const ( + SalesLeadStageUnknown SalesLeadStage = "unknown" + SalesLeadStageConsulting SalesLeadStage = "consulting" + SalesLeadStageComparing SalesLeadStage = "comparing" + SalesLeadStageAppointment SalesLeadStage = "appointment" + SalesLeadStageReadyToBuy SalesLeadStage = "ready_to_buy" + SalesLeadStageAfterSales SalesLeadStage = "after_sales" +) + +func IsValidSalesLeadStatus(value string) bool { + switch SalesLeadStatus(value) { + case SalesLeadStatusNew, SalesLeadStatusFollowing, SalesLeadStatusVisited, SalesLeadStatusConverted, SalesLeadStatusInvalid, SalesLeadStatusClosed: + return true + default: + return false + } +} diff --git a/internal/repositories/digital_store_delivery_record_repository.go b/internal/repositories/digital_store_delivery_record_repository.go new file mode 100644 index 00000000..72f5ee16 --- /dev/null +++ b/internal/repositories/digital_store_delivery_record_repository.go @@ -0,0 +1,47 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var DigitalStoreDeliveryRecordRepository = newDigitalStoreDeliveryRecordRepository() + +func newDigitalStoreDeliveryRecordRepository() *digitalStoreDeliveryRecordRepository { + return &digitalStoreDeliveryRecordRepository{} +} + +type digitalStoreDeliveryRecordRepository struct{} + +func (r *digitalStoreDeliveryRecordRepository) Get(db *gorm.DB, id int64) *models.DigitalStoreDeliveryRecord { + ret := &models.DigitalStoreDeliveryRecord{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *digitalStoreDeliveryRecordRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.DigitalStoreDeliveryRecord { + ret := &models.DigitalStoreDeliveryRecord{} + if err := cnd.FindOne(db, ret); err != nil { + return nil + } + return ret +} + +func (r *digitalStoreDeliveryRecordRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.DigitalStoreDeliveryRecord, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.DigitalStoreDeliveryRecord{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *digitalStoreDeliveryRecordRepository) Create(db *gorm.DB, item *models.DigitalStoreDeliveryRecord) error { + return db.Create(item).Error +} diff --git a/internal/repositories/lead_follow_up_repository.go b/internal/repositories/lead_follow_up_repository.go new file mode 100644 index 00000000..de2e7e81 --- /dev/null +++ b/internal/repositories/lead_follow_up_repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var LeadFollowUpRepository = newLeadFollowUpRepository() + +func newLeadFollowUpRepository() *leadFollowUpRepository { + return &leadFollowUpRepository{} +} + +type leadFollowUpRepository struct { +} + +func (r *leadFollowUpRepository) Get(db *gorm.DB, id int64) *models.LeadFollowUp { + ret := &models.LeadFollowUp{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *leadFollowUpRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.LeadFollowUp) { + cnd.Find(db, &list) + return +} + +func (r *leadFollowUpRepository) Create(db *gorm.DB, t *models.LeadFollowUp) error { + return db.Create(t).Error +} diff --git a/internal/repositories/product_repository.go b/internal/repositories/product_repository.go new file mode 100644 index 00000000..b8de153a --- /dev/null +++ b/internal/repositories/product_repository.go @@ -0,0 +1,70 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var ProductRepository = newProductRepository() + +func newProductRepository() *productRepository { + return &productRepository{} +} + +type productRepository struct { +} + +func (r *productRepository) Get(db *gorm.DB, id int64) *models.Product { + ret := &models.Product{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *productRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.Product) { + cnd.Find(db, &list) + return +} + +func (r *productRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.Product { + ret := &models.Product{} + if err := cnd.FindOne(db, ret); err != nil { + return nil + } + return ret +} + +func (r *productRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.Product, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *productRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.Product, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.Product{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *productRepository) Create(db *gorm.DB, t *models.Product) error { + return db.Create(t).Error +} + +func (r *productRepository) Update(db *gorm.DB, t *models.Product) error { + return db.Save(t).Error +} + +func (r *productRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.Product{}).Where("id = ?", id).Updates(columns).Error +} + +func (r *productRepository) Delete(db *gorm.DB, id int64) error { + return db.Delete(&models.Product{}, "id = ?", id).Error +} diff --git a/internal/repositories/promotion_repository.go b/internal/repositories/promotion_repository.go new file mode 100644 index 00000000..f95acaeb --- /dev/null +++ b/internal/repositories/promotion_repository.go @@ -0,0 +1,57 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var PromotionRepository = newPromotionRepository() + +func newPromotionRepository() *promotionRepository { + return &promotionRepository{} +} + +type promotionRepository struct { +} + +func (r *promotionRepository) Get(db *gorm.DB, id int64) *models.Promotion { + ret := &models.Promotion{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *promotionRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.Promotion { + ret := &models.Promotion{} + if err := cnd.FindOne(db, ret); err != nil { + return nil + } + return ret +} + +func (r *promotionRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.Promotion, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *promotionRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.Promotion, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.Promotion{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *promotionRepository) Create(db *gorm.DB, t *models.Promotion) error { + return db.Create(t).Error +} + +func (r *promotionRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.Promotion{}).Where("id = ?", id).Updates(columns).Error +} diff --git a/internal/repositories/sales_lead_repository.go b/internal/repositories/sales_lead_repository.go new file mode 100644 index 00000000..fb9be13b --- /dev/null +++ b/internal/repositories/sales_lead_repository.go @@ -0,0 +1,82 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var SalesLeadRepository = newSalesLeadRepository() + +func newSalesLeadRepository() *salesLeadRepository { + return &salesLeadRepository{} +} + +type salesLeadRepository struct { +} + +func (r *salesLeadRepository) Get(db *gorm.DB, id int64) *models.SalesLead { + ret := &models.SalesLead{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *salesLeadRepository) Take(db *gorm.DB, where ...interface{}) *models.SalesLead { + ret := &models.SalesLead{} + if err := db.Take(ret, where...).Error; err != nil { + return nil + } + return ret +} + +func (r *salesLeadRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.SalesLead) { + cnd.Find(db, &list) + return +} + +func (r *salesLeadRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.SalesLead { + ret := &models.SalesLead{} + if err := cnd.FindOne(db, &ret); err != nil { + return nil + } + return ret +} + +func (r *salesLeadRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.SalesLead, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *salesLeadRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.SalesLead, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.SalesLead{}) + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *salesLeadRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { + return cnd.Count(db, &models.SalesLead{}) +} + +func (r *salesLeadRepository) Create(db *gorm.DB, t *models.SalesLead) error { + return db.Create(t).Error +} + +func (r *salesLeadRepository) Update(db *gorm.DB, t *models.SalesLead) error { + return db.Save(t).Error +} + +func (r *salesLeadRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) error { + return db.Model(&models.SalesLead{}).Where("id = ?", id).Updates(columns).Error +} + +func (r *salesLeadRepository) Delete(db *gorm.DB, id int64) { + db.Delete(&models.SalesLead{}, "id = ?", id) +} diff --git a/internal/services/conversation_follow_up_advice_test.go b/internal/services/conversation_follow_up_advice_test.go new file mode 100644 index 00000000..6d1c61f1 --- /dev/null +++ b/internal/services/conversation_follow_up_advice_test.go @@ -0,0 +1,146 @@ +//go:build dev + +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/mlogclub/simple/sqls" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestConversationBuildFollowUpAdviceUsesSalesLead(t *testing.T) { + db := setupConversationFollowUpAdviceTestDB(t) + now := time.Now() + conversation := models.Conversation{ + CustomerID: 8, + CustomerName: "王先生", + Status: enums.IMConversationStatusActive, + LastMessageSummary: "客户想买偏硬床垫,预算两万左右。", + LastMessageAt: now, + LastActiveAt: now, + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := models.Message{ + ConversationID: conversation.ID, + ClientMsgID: "lead-customer-1", + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我周末能去上海旗舰店试一下吗?", + SentAt: &now, + } + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + lead := models.SalesLead{ + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + CustomerName: "王先生", + Phone: "13800000000", + City: "上海", + BudgetMin: 18000, + BudgetMax: 22000, + InterestedProducts: "T9 旗舰床垫", + DemandSummary: "偏硬支撑,周末想预约到店试躺", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + Status: enums.SalesLeadStatusNew, + LastMessageID: message.ID, + } + if err := db.Create(&lead).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + followUp := models.LeadFollowUp{ + LeadID: lead.ID, + OperatorName: "顾问A", + Content: "已确认客户关注偏硬支撑。", + NextAction: "确认到店时间", + CreatedAt: now, + } + if err := db.Create(&followUp).Error; err != nil { + t.Fatalf("create follow-up: %v", err) + } + + advice, err := ConversationService.BuildFollowUpAdvice(conversation.ID) + if err != nil { + t.Fatalf("BuildFollowUpAdvice() error = %v", err) + } + if advice.Source != "sales_lead" || advice.LeadID != lead.ID { + t.Fatalf("unexpected source/lead: source=%q lead=%d", advice.Source, advice.LeadID) + } + for _, want := range []string{"王先生", "T9 旗舰床垫", "最近对话", "周末能去上海旗舰店"} { + if !strings.Contains(advice.CopyText, want) { + t.Fatalf("CopyText missing %q: %s", want, advice.CopyText) + } + } +} + +func TestConversationBuildFollowUpAdviceWithoutSalesLead(t *testing.T) { + db := setupConversationFollowUpAdviceTestDB(t) + now := time.Now() + conversation := models.Conversation{ + CustomerName: "李女士", + Status: enums.IMConversationStatusAIServing, + LastMessageSummary: "客户询问儿童房床垫和除螨面料。", + LastMessageAt: now, + LastActiveAt: now, + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + messages := []models.Message{ + {ConversationID: conversation.ID, ClientMsgID: "conversation-customer-1", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "小朋友睡,想要护脊一点的。", SentAt: &now}, + {ConversationID: conversation.ID, ClientMsgID: "conversation-ai-1", SenderType: enums.IMSenderTypeAI, MessageType: enums.IMMessageTypeText, Content: "可以先看儿童护脊系列。", SentAt: &now}, + } + if err := db.Create(&messages).Error; err != nil { + t.Fatalf("create messages: %v", err) + } + + advice, err := ConversationService.BuildFollowUpAdvice(conversation.ID) + if err != nil { + t.Fatalf("BuildFollowUpAdvice() error = %v", err) + } + if advice.Source != "conversation" || advice.LeadID != 0 { + t.Fatalf("unexpected source/lead: source=%q lead=%d", advice.Source, advice.LeadID) + } + for _, want := range []string{"【会话跟进摘要】", "李女士", "小朋友睡", "尚未形成销售线索"} { + if !strings.Contains(advice.CopyText, want) { + t.Fatalf("CopyText missing %q: %s", want, advice.CopyText) + } + } + if len(advice.RiskHints) == 0 { + t.Fatalf("expected risk hints") + } +} + +func setupConversationFollowUpAdviceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &models.Conversation{}, + &models.Message{}, + &models.SalesLead{}, + &models.LeadFollowUp{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + return db +} diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go index a9ed0f32..9caaed78 100644 --- a/internal/services/conversation_human_dispatch_service.go +++ b/internal/services/conversation_human_dispatch_service.go @@ -42,10 +42,11 @@ const ( ) type HandoffDecisionResult struct { - Decision HandoffDecisionType - TeamID int64 - AssigneeID int64 - Message string + Decision HandoffDecisionType + TeamID int64 + AssigneeID int64 + Message string + ContextText string } type conversationHumanDispatchService struct{} @@ -74,6 +75,9 @@ func (s *conversationHumanDispatchService) TryOffHoursHandoffByAIWithRequestID(c if err := s.sendAITextWithRequestID(conversationID, aiAgent.ID, HandoffOffHoursMessage, requestID); err != nil { return true, err } + if err := s.ensureOffHoursFollowUpLead(conversation, aiAgent, reason); err != nil { + return true, err + } return true, nil } @@ -165,6 +169,7 @@ func (s *conversationHumanDispatchService) dispatchAfterHandoffWithRequestID(con if err := s.sendAITextWithRequestID(conversationID, aiAgentID, HandoffWaitingMessage, requestID); err != nil { return nil, err } + contextText := ConversationService.BuildHandoffContext(ConversationService.Get(conversationID), reason) candidates, _, err := ConversationDispatchService.pickDispatchCandidates(activeTeamIDs, time.Now()) if err != nil { @@ -184,13 +189,15 @@ func (s *conversationHumanDispatchService) dispatchAfterHandoffWithRequestID(con OperatorID: systemDispatchPrincipal().UserID, Reason: "自动分配", AssignType: events.ConversationAssignTypeAutoAssign, + ContextText: contextText, }) } return &HandoffDecisionResult{ - Decision: HandoffDecisionAssigned, - TeamID: dispatched.CurrentTeamID, - AssigneeID: dispatched.CurrentAssigneeID, - Message: HandoffWaitingMessage, + Decision: HandoffDecisionAssigned, + TeamID: dispatched.CurrentTeamID, + AssigneeID: dispatched.CurrentAssigneeID, + Message: HandoffWaitingMessage, + ContextText: contextText, }, nil } } @@ -203,13 +210,17 @@ func (s *conversationHumanDispatchService) dispatchAfterHandoffWithRequestID(con if teamPoolConversation != nil { WsService.PublishConversationChanged(teamPoolConversation, enums.IMRealtimeEventConversationUpdated) } - return &HandoffDecisionResult{Decision: HandoffDecisionTeamPool, TeamID: teamID, Message: HandoffWaitingMessage}, nil + return &HandoffDecisionResult{Decision: HandoffDecisionTeamPool, TeamID: teamID, Message: HandoffWaitingMessage, ContextText: contextText}, nil } func (s *conversationHumanDispatchService) markHandoff(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) error { now := time.Now() trimmedReason := strings.TrimSpace(reason) return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ "handoff_at": now, "handoff_reason": trimmedReason, @@ -222,7 +233,10 @@ func (s *conversationHumanDispatchService) markHandoff(conversationID int64, aiA }); err != nil { return err } - return ConversationEventLogService.CreateEventWithRequestID(ctx, conversationID, requestID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", trimmedReason) + return ConversationEventLogService.CreateEventWithRequestID(ctx, conversationID, requestID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", ConversationService.buildEventPayload(map[string]any{ + "reason": trimmedReason, + "context": ConversationService.BuildHandoffContext(conversation, trimmedReason), + })) }) } @@ -259,6 +273,7 @@ func (s *conversationHumanDispatchService) moveToTeamPoolWithRequestID(conversat "toTeamId": teamID, "reason": strings.TrimSpace(reason), "decision": string(HandoffDecisionTeamPool), + "context": ConversationService.BuildHandoffContext(current, reason), })); err != nil { return err } @@ -302,6 +317,91 @@ func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64 }) } +func (s *conversationHumanDispatchService) ensureOffHoursFollowUpLead(conversation *models.Conversation, aiAgent models.AIAgent, reason string) error { + if conversation == nil || conversation.ID <= 0 { + return nil + } + now := time.Now() + nextFollowUpAt := nextOffHoursFollowUpTime(now) + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + trimmedReason = "客户在非服务时间请求人工" + } + summary := limitText("非服务时间转人工:"+trimmedReason, 500) + stage := enums.SalesLeadStageConsulting + if containsAnyLeadText(trimmedReason, "售后", "投诉", "退款", "退货", "换货", "质保", "异响", "故障", "不满意", "差评") { + stage = enums.SalesLeadStageAfterSales + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + lead := repositories.SalesLeadRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Where("conversation_id = ?", conversation.ID). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Desc("id")) + if lead == nil && conversation.CustomerID > 0 { + lead = repositories.SalesLeadRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Where("customer_id = ?", conversation.CustomerID). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Desc("id")) + } + if lead == nil { + lead = &models.SalesLead{ + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + CustomerName: strings.TrimSpace(conversation.CustomerName), + DemandSummary: summary, + IntentLevel: enums.SalesLeadIntentMedium, + BuyingStage: stage, + SourceChannel: "off_hours_handoff", + Status: enums.SalesLeadStatusNew, + NextFollowUpAt: &nextFollowUpAt, + Remark: "非服务时间请求人工,待顾问跟进", + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + CreateUserName: aiAgent.Name, + UpdateUserName: aiAgent.Name, + }, + } + return repositories.SalesLeadRepository.Create(ctx.Tx, lead) + } + updates := map[string]any{ + "conversation_id": conversation.ID, + "updated_at": now, + "update_user_name": aiAgent.Name, + "source_channel": "off_hours_handoff", + "last_message_id": conversation.LastMessageID, + "next_follow_up_at": &nextFollowUpAt, + } + if strings.TrimSpace(lead.CustomerName) == "" && strings.TrimSpace(conversation.CustomerName) != "" { + updates["customer_name"] = strings.TrimSpace(conversation.CustomerName) + } + if strings.TrimSpace(lead.DemandSummary) == "" { + updates["demand_summary"] = summary + } else if !strings.Contains(lead.DemandSummary, trimmedReason) { + updates["demand_summary"] = limitText(lead.DemandSummary+"\n"+summary, 1000) + } + if lead.BuyingStage == enums.SalesLeadStageUnknown || lead.BuyingStage == enums.SalesLeadStageConsulting { + updates["buying_stage"] = stage + } + if lead.IntentLevel == enums.SalesLeadIntentUnknown || lead.IntentLevel == enums.SalesLeadIntentLow { + updates["intent_level"] = enums.SalesLeadIntentMedium + } + if lead.Status == enums.SalesLeadStatusNew || lead.Status == "" { + updates["status"] = enums.SalesLeadStatusNew + } + return repositories.SalesLeadRepository.Updates(ctx.Tx, lead.ID, updates) + }) +} + +func nextOffHoursFollowUpTime(now time.Time) time.Time { + target := time.Date(now.Year(), now.Month(), now.Day(), 9, 30, 0, 0, now.Location()) + if now.Before(target) { + return target + } + return target.AddDate(0, 0, 1) +} + func (s *conversationHumanDispatchService) createEvent(conversationID int64, eventType enums.IMEventType, senderType enums.IMSenderType, senderID int64, content, payload string) error { return s.createEventWithRequestID(conversationID, "", eventType, senderType, senderID, content, payload) } diff --git a/internal/services/conversation_human_dispatch_service_test.go b/internal/services/conversation_human_dispatch_service_test.go index 3922ddee..90a76a09 100644 --- a/internal/services/conversation_human_dispatch_service_test.go +++ b/internal/services/conversation_human_dispatch_service_test.go @@ -38,11 +38,24 @@ func TestConversationHumanDispatchAIHandoffOffHoursKeepsAIServingAndSendsNotice( t.Fatalf("expected handoffAt to stay nil, got %v", current.HandoffAt) } + var lead models.SalesLead + if err := db.Where("conversation_id = ?", conversation.ID).First(&lead).Error; err != nil { + t.Fatalf("expected off-hours handoff lead: %v", err) + } + if lead.OwnerUserID != 0 || lead.NextFollowUpAt == nil || lead.SourceChannel != "off_hours_handoff" { + t.Fatalf("unexpected off-hours lead: %+v", lead) + } + if lead.Status != enums.SalesLeadStatusNew || lead.IntentLevel != enums.SalesLeadIntentMedium { + t.Fatalf("expected new medium-intent follow-up lead, got %+v", lead) + } + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) if message == nil { t.Fatalf("expected off-hours notice message") } - if message.SenderType != enums.IMSenderTypeAI || !strings.Contains(message.Content, "Human support is currently outside service hours") { + if message.SenderType != enums.IMSenderTypeAI || + (!strings.Contains(message.Content, "Human support is currently outside service hours") && + !strings.Contains(message.Content, "当前不在人工客服服务时间内")) { t.Fatalf("unexpected off-hours message: %+v", message) } } @@ -181,6 +194,66 @@ func TestConversationAutoAssignManualDispatchFallsBackToTeamPool(t *testing.T) { } } +func TestConversationResumeAIConversationReturnsActiveConversationToAI(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusActive) + if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Updates(map[string]any{ + "current_assignee_id": int64(101), + "current_team_id": int64(1), + }).Error; err != nil { + t.Fatalf("update conversation assignment: %v", err) + } + if err := db.Create(&models.ConversationAssignment{ + ConversationID: conversation.ID, + ToUserID: 101, + AssignType: string(enums.IMAssignmentTypeAssign), + Reason: "人工接管", + Status: enums.IMAssignmentStatusActive, + CreatedAt: time.Now(), + OperatorID: 101, + }).Error; err != nil { + t.Fatalf("create assignment: %v", err) + } + + err := services.ConversationService.ResumeAIConversation(conversation.ID, "处理完成", &dto.AuthPrincipal{UserID: 101, Username: "agent"}) + if err != nil { + t.Fatalf("ResumeAIConversation() error = %v", err) + } + current := services.ConversationService.Get(conversation.ID) + if current.Status != enums.IMConversationStatusAIServing || current.CurrentAssigneeID != 0 || current.CurrentTeamID != 0 { + t.Fatalf("expected ai-serving conversation without assignee, got %+v", current) + } + var assignment models.ConversationAssignment + if err := db.Where("conversation_id = ?", conversation.ID).First(&assignment).Error; err != nil { + t.Fatalf("load assignment: %v", err) + } + if assignment.Status != enums.IMAssignmentStatusInactive || assignment.FinishedAt == nil { + t.Fatalf("expected finished assignment, got %+v", assignment) + } + var event models.ConversationEventLog + if err := db.Where("conversation_id = ? AND content = ?", conversation.ID, "恢复 AI 接待").First(&event).Error; err != nil { + t.Fatalf("expected resume ai event: %v", err) + } +} + +func TestConversationResumeAIConversationRejectsHumanOnlyConversation(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1") + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusActive) + if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Updates(map[string]any{ + "service_mode": enums.IMConversationServiceModeHumanOnly, + "current_assignee_id": int64(101), + }).Error; err != nil { + t.Fatalf("update conversation service mode: %v", err) + } + + err := services.ConversationService.ResumeAIConversation(conversation.ID, "处理完成", &dto.AuthPrincipal{UserID: 101, Username: "agent"}) + if err == nil { + t.Fatalf("expected human-only conversation to reject resume ai") + } +} + func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) @@ -213,6 +286,7 @@ func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB { &models.ConversationEventLog{}, &models.ConversationReadState{}, &models.Message{}, + &models.SalesLead{}, &models.ChannelMessageOutbox{}, ); err != nil { t.Fatalf("auto migrate error = %v", err) diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index b4db8067..c0e71fb7 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -3,6 +3,7 @@ package services import ( "context" "encoding/json" + "fmt" "log/slog" "agent-desk/internal/events" @@ -10,6 +11,7 @@ import ( "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/eventbus" @@ -402,6 +404,54 @@ func (s *conversationService) CloseCustomerConversation(conversationID int64, ex return s.closeConversation(conversationID, enums.IMSenderTypeCustomer, "", nil) } +func (s *conversationService) ResumeAIConversation(conversationID int64, reason string, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "人工处理完成,恢复 AI 接待" + } + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParamI18n("error.e0116") + } + if !s.canResumeAIConversation(conversation, operator) { + return errorsx.ForbiddenI18n("error.e0221") + } + now := time.Now() + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusAIServing, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "恢复 AI 接待", s.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusAIServing, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": int64(0), + "fromTeamId": conversation.CurrentTeamID, + "toTeamId": int64(0), + "reason": reason, + })) + }); err != nil { + return err + } + if conversation := s.Get(conversationID); conversation != nil { + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated) + } + return nil +} + func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error { if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) @@ -688,6 +738,266 @@ func (s *conversationService) BuildConversationSummary(conversation *models.Conv return strings.TrimSpace(conversation.CustomerName) } +func (s *conversationService) BuildHandoffContext(conversation *models.Conversation, reason string) string { + if conversation == nil { + return "" + } + lines := make([]string, 0, 10) + if name := strings.TrimSpace(conversation.CustomerName); name != "" { + lines = append(lines, "客户: "+name) + } + if lead := s.findConversationLead(conversation); lead != nil { + lines = append(lines, buildLeadHandoffLines(lead)...) + } + if summary := strings.TrimSpace(s.BuildConversationSummary(conversation)); summary != "" { + lines = append(lines, "会话摘要: "+summary) + } + if recent := s.buildRecentMessageSummary(conversation.ID, 4); recent != "" { + lines = append(lines, "最近对话:\n"+recent) + } + return strings.Join(dedupeNonBlankLines(lines), "\n") +} + +func (s *conversationService) BuildFollowUpAdvice(conversationID int64) (response.ConversationFollowUpAdviceResponse, error) { + ret := response.ConversationFollowUpAdviceResponse{ + ConversationID: conversationID, + Source: "conversation", + } + conversation := s.Get(conversationID) + if conversation == nil { + return ret, errorsx.InvalidParamI18n("error.e0116") + } + if lead := s.findConversationLead(conversation); lead != nil { + followUps := SalesLeadService.FindFollowUps(lead.ID) + ret.LeadID = lead.ID + ret.Source = "sales_lead" + ret.SalesLeadFollowUpAdviceResult = SalesLeadService.BuildFollowUpAdvice(lead, followUps) + if recent := strings.TrimSpace(s.buildRecentMessageSummary(conversation.ID, 4)); recent != "" && !strings.Contains(ret.CopyText, "最近对话:") { + ret.CopyText = strings.TrimSpace(ret.CopyText) + "\n最近对话:\n" + recent + } + return ret, nil + } + + ret.SalesLeadFollowUpAdviceResult = s.buildConversationOnlyFollowUpAdvice(conversation) + return ret, nil +} + +func (s *conversationService) buildConversationOnlyFollowUpAdvice(conversation *models.Conversation) response.SalesLeadFollowUpAdviceResult { + if conversation == nil { + return response.SalesLeadFollowUpAdviceResult{} + } + customerName := strings.TrimSpace(conversation.CustomerName) + if customerName == "" { + customerName = fmt.Sprintf("会话#%d客户", conversation.ID) + } + summary := strings.TrimSpace(s.BuildConversationSummary(conversation)) + recent := strings.TrimSpace(s.buildRecentMessageSummary(conversation.ID, 6)) + customerSummaryParts := []string{customerName} + if conversation.CustomerID > 0 { + customerSummaryParts = append(customerSummaryParts, fmt.Sprintf("客户ID %d", conversation.CustomerID)) + } + if summary != "" && summary != customerName { + customerSummaryParts = append(customerSummaryParts, limitText(summary, 160)) + } + nextAction := "先确认客户核心需求、联系方式、预算区间和是否方便到店体验,再根据需求建立销售线索并安排跟进。" + scriptName := "您好" + if customerName != "" && !strings.Contains(customerName, "会话#") { + scriptName = customerName + "您好" + } + script := fmt.Sprintf("%s,我看到您刚才咨询的内容。为了帮您推荐得更准,我想再确认一下使用人、尺寸、软硬偏好、预算以及是否方便到店体验,我会据此给您整理 1-2 个更合适的方案。", scriptName) + riskHints := []string{"尚未形成销售线索"} + if recent == "" { + riskHints = append(riskHints, "最近对话内容不足") + } + if conversation.CustomerID <= 0 { + riskHints = append(riskHints, "未关联 CRM 客户") + } + + copyLines := []string{ + "【会话跟进摘要】", + "客户:" + customerName, + fmt.Sprintf("会话ID:%d", conversation.ID), + } + if summary != "" { + copyLines = append(copyLines, "会话摘要:"+limitText(summary, 240)) + } + if recent != "" { + copyLines = append(copyLines, "最近对话:\n"+recent) + } + copyLines = append(copyLines, "建议下一步:"+nextAction, "建议话术:"+script) + if len(riskHints) > 0 { + copyLines = append(copyLines, "注意事项:"+strings.Join(riskHints, ";")) + } + + return response.SalesLeadFollowUpAdviceResult{ + CustomerSummary: strings.Join(dedupeNonBlankLines(customerSummaryParts), "|"), + NextAction: nextAction, + Script: script, + CopyText: strings.Join(copyLines, "\n"), + RiskHints: riskHints, + } +} + +func (s *conversationService) findConversationLead(conversation *models.Conversation) *models.SalesLead { + if conversation == nil { + return nil + } + if conversation.ID > 0 { + if lead := repositories.SalesLeadRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversation.ID). + Where("status <> ?", enums.SalesLeadStatusClosed). + Desc("id")); lead != nil { + return lead + } + } + if conversation.CustomerID > 0 { + return repositories.SalesLeadRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("customer_id = ?", conversation.CustomerID). + Where("status <> ?", enums.SalesLeadStatusClosed). + Desc("id")) + } + return nil +} + +func buildLeadHandoffLines(lead *models.SalesLead) []string { + if lead == nil { + return nil + } + lines := make([]string, 0, 8) + if name := strings.TrimSpace(lead.CustomerName); name != "" { + lines = append(lines, "线索姓名: "+name) + } + if phone := strings.TrimSpace(lead.Phone); phone != "" { + lines = append(lines, "手机号: "+phone) + } + if wechat := strings.TrimSpace(lead.WeChat); wechat != "" { + lines = append(lines, "微信: "+wechat) + } + if city := strings.TrimSpace(lead.City); city != "" { + lines = append(lines, "城市: "+city) + } + if product := strings.TrimSpace(lead.InterestedProducts); product != "" { + lines = append(lines, "关注产品: "+product) + } + if budget := formatLeadBudget(lead.BudgetMin, lead.BudgetMax); budget != "" { + lines = append(lines, "预算: "+budget) + } + if appointment := formatLeadAppointment(lead); appointment != "" { + lines = append(lines, "预约: "+appointment) + } + lines = append(lines, "意向等级: "+salesLeadIntentLabel(lead.IntentLevel)) + lines = append(lines, "购买阶段: "+salesLeadStageLabel(lead.BuyingStage)) + if demand := strings.TrimSpace(lead.DemandSummary); demand != "" { + lines = append(lines, "需求摘要: "+limitText(demand, 180)) + } + return lines +} + +func formatLeadAppointment(lead *models.SalesLead) string { + if lead == nil { + return "" + } + parts := make([]string, 0, 4) + if lead.AppointmentAt != nil { + parts = append(parts, lead.AppointmentAt.Format("2006-01-02 15:04")) + } + if text := strings.TrimSpace(lead.AppointmentTimeText); text != "" { + parts = append(parts, text) + } + if store := strings.TrimSpace(lead.AppointmentStore); store != "" { + parts = append(parts, store) + } + if lead.AppointmentPeople > 0 { + parts = append(parts, fmt.Sprintf("%d人", lead.AppointmentPeople)) + } + return strings.Join(dedupeNonBlankLines(parts), " / ") +} + +func (s *conversationService) buildRecentMessageSummary(conversationID int64, limit int) string { + if conversationID <= 0 { + return "" + } + if limit <= 0 { + limit = 4 + } + messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("message_type = ?", enums.IMMessageTypeText). + Where("recalled_at IS NULL"). + Desc("id"). + Limit(limit)) + if len(messages) == 0 { + return "" + } + slices.Reverse(messages) + lines := make([]string, 0, len(messages)) + for _, message := range messages { + content := limitText(message.Content, 120) + if content == "" { + continue + } + lines = append(lines, enums.GetIMSenderTypeLabel(message.SenderType)+": "+content) + } + return strings.Join(lines, "\n") +} + +func dedupeNonBlankLines(lines []string) []string { + ret := make([]string, 0, len(lines)) + seen := make(map[string]bool, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || seen[line] { + continue + } + seen[line] = true + ret = append(ret, line) + } + return ret +} + +func formatLeadBudget(min, max int64) string { + switch { + case min > 0 && max > 0: + return fmt.Sprintf("%d-%d元", min, max) + case max > 0: + return fmt.Sprintf("%d元左右", max) + case min > 0: + return fmt.Sprintf("%d元以上", min) + default: + return "" + } +} + +func salesLeadIntentLabel(value enums.SalesLeadIntent) string { + switch value { + case enums.SalesLeadIntentHigh: + return "高" + case enums.SalesLeadIntentMedium: + return "中" + case enums.SalesLeadIntentLow: + return "低" + default: + return "未知" + } +} + +func salesLeadStageLabel(value enums.SalesLeadStage) string { + switch value { + case enums.SalesLeadStageConsulting: + return "咨询了解" + case enums.SalesLeadStageComparing: + return "对比方案" + case enums.SalesLeadStageAppointment: + return "预约到店" + case enums.SalesLeadStageReadyToBuy: + return "临门购买" + case enums.SalesLeadStageAfterSales: + return "售后咨询" + default: + return "未知" + } +} + func (s *conversationService) getCustomerName(db *gorm.DB, customerID int64) string { if customerID <= 0 { return "" @@ -720,6 +1030,24 @@ func (s *conversationService) canTransferConversation(conversation *models.Conve conversation.CurrentAssigneeID == operator.UserID } +func (s *conversationService) canResumeAIConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool { + if conversation == nil || operator == nil { + return false + } + if conversation.ServiceMode == enums.IMConversationServiceModeHumanOnly || + conversation.ServiceMode == enums.IMConversationServiceModeAIOnly || + conversation.Status == enums.IMConversationStatusClosed || + conversation.Status == enums.IMConversationStatusAIServing { + return false + } + if s.isAdmin(operator) { + return true + } + return conversation.Status == enums.IMConversationStatusActive && + conversation.CurrentAssigneeID > 0 && + conversation.CurrentAssigneeID == operator.UserID +} + func (s *conversationService) isAdmin(operator *dto.AuthPrincipal) bool { if operator == nil { return false diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 5b659df5..db0a5bd4 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -1,9 +1,12 @@ package cronx import ( + "agent-desk/internal/pkg/config" "agent-desk/internal/services" "fmt" "log/slog" + "strings" + "time" "github.com/robfig/cron/v3" ) @@ -28,6 +31,8 @@ func Init() { } }) + addDailyBusinessReportJob(c) + c.Start() } @@ -36,3 +41,28 @@ func addFunc(c *cron.Cron, sepc string, cmd func()) { slog.Error("add cron func error", slog.Any("err", err)) } } + +func addDailyBusinessReportJob(c *cron.Cron) { + cfg := config.Current() + dailyCfg := cfg.Notify.DailyReport + if !dailyCfg.Enabled { + return + } + spec := strings.TrimSpace(dailyCfg.Cron) + if spec == "" { + spec = "0 9 * * *" + } + addFunc(c, spec, func() { + reportDate := time.Now().AddDate(0, 0, dailyCfg.DateOffsetDays).Format(time.DateOnly) + resp, err := services.DashboardService.SendScheduledDailyBusinessReportWebhook(reportDate, cfg.LanguageOrDefault()) + if err != nil { + slog.Error("send scheduled daily business report failed", "error", err, "reportDate", reportDate) + return + } + if resp.Sent { + slog.Info("scheduled daily business report sent", "reportDate", reportDate) + } else { + slog.Warn("scheduled daily business report skipped", "reportDate", reportDate, "message", resp.Message) + } + }) +} diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 9fd30d1f..9a008832 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" @@ -24,6 +25,8 @@ func newDashboardService() *dashboardService { type dashboardService struct { } +const dashboardDailyReportLastSentConfigKey = "dashboard.daily_report.last_sent_date" + func (s *dashboardService) GetOverview(rangeValue string, locale string) response.DashboardOverviewResponse { locale = i18nx.NormalizeLocale(locale) now := time.Now() @@ -41,78 +44,1856 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons enums.IMConversationStatusActive, }) }) - pendingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("status = ?", enums.IMConversationStatusPending) - }) + pendingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.IMConversationStatusPending) + }) + + agentProfiles := repositories.DashboardRepository.ListEnabledAgentProfiles(db) + agentTeams := repositories.DashboardRepository.ListEnabledAgentTeams(db) + activeSchedules := repositories.DashboardRepository.ListActiveTeamSchedules(db, now, now) + activeConversations := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status IN ?", []enums.IMConversationStatus{ + enums.IMConversationStatusAIServing, + enums.IMConversationStatusPending, + enums.IMConversationStatusActive, + }) + }) + + onlineAgents, busyAgents, offlineAgents, teamLoads := s.buildAgentStats(now, agentTeams, agentProfiles, activeSchedules, activeConversations) + + enabledAIAgentCount := repositories.DashboardRepository.CountAIAgents(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + enabledChannelCount := repositories.DashboardRepository.CountChannels(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + knowledgeRetrieveCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ?", todayStart) + }) + knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4}) + }) + skillRunFailCount := repositories.DashboardRepository.CountSkillRunLogs(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND error_message <> ''", todayStart) + }) + aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("handoff_at >= ?", todayStart) + }) + digitalStoreStats := s.buildDigitalStoreStats(db, todayStart, trendStart, now, conversationTodayCount, aiHandoffCount, locale) + + enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status = ?", enums.StatusOk) + }) + alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules, locale) + + return response.DashboardOverviewResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + Summary: response.DashboardSummaryResponse{ + TodayNewConversations: conversationTodayCount, + ProcessingConversations: processingConversationCount, + PendingDispatchConversations: pendingConversationCount, + OnlineAgents: onlineAgents, + AIServiceRate: calcAIServiceRate(activeConversations), + }, + ConversationStats: response.DashboardSectionStatsResponse{ + StatusDistribution: buildConversationStatusDistribution(db, locale), + Trend: buildConversationTrend(db, trendStart), + }, + AgentStats: response.DashboardAgentStatsResponse{ + OnlineAgents: onlineAgents, + BusyAgents: busyAgents, + OfflineAgents: offlineAgents, + TeamLoads: teamLoads, + }, + AIStats: response.DashboardAIStatsResponse{ + EnabledAIAgents: enabledAIAgentCount, + EnabledChannels: enabledChannelCount, + TodayKnowledgeRetrieves: knowledgeRetrieveCount, + TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount, + TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount), + TodaySkillRunFailCount: skillRunFailCount, + TodayAIHandoffCount: aiHandoffCount, + }, + DigitalStoreStats: digitalStoreStats, + Alerts: alerts, + QuickLinks: buildDashboardQuickLinks(locale), + } +} + +func (s *dashboardService) GetDailyBusinessReport(dateValue string, locale string) response.DashboardDailyBusinessReportResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + reportDate, dayStart, dayEnd := resolveReportDay(dateValue, now) + db := sqls.DB() + validLeadStatuses := []enums.SalesLeadStatus{ + enums.SalesLeadStatusNew, + enums.SalesLeadStatusFollowing, + enums.SalesLeadStatusVisited, + enums.SalesLeadStatusConverted, + } + + conversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND created_at < ?", dayStart, dayEnd) + }) + + var aiReplyCount int64 + db.Model(&models.Message{}). + Where("created_at >= ? AND created_at < ? AND sender_type = ?", dayStart, dayEnd, enums.IMSenderTypeAI). + Count(&aiReplyCount) + + handoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("handoff_at >= ? AND handoff_at < ?", dayStart, dayEnd) + }) + + var leadCount int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status IN ?", dayStart, dayEnd, validLeadStatuses). + Count(&leadCount) + + var highIntentCount int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status IN ? AND intent_level = ?", dayStart, dayEnd, validLeadStatuses, enums.SalesLeadIntentHigh). + Count(&highIntentCount) + + var appointmentCount int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status IN ? AND buying_stage IN ?", dayStart, dayEnd, validLeadStatuses, []enums.SalesLeadStage{ + enums.SalesLeadStageAppointment, + enums.SalesLeadStageReadyToBuy, + }). + Count(&appointmentCount) + + var convertedCount int64 + db.Model(&models.SalesLead{}). + Where("updated_at >= ? AND updated_at < ? AND status = ?", dayStart, dayEnd, enums.SalesLeadStatusConverted). + Count(&convertedCount) + + unresolvedCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("status <> ? AND last_active_at >= ? AND last_active_at < ?", enums.IMConversationStatusClosed, dayStart, dayEnd) + }) + + var activeProductCount int64 + db.Model(&models.Product{}). + Where("status = ?", enums.StatusOk). + Count(&activeProductCount) + + var activePromotionCount int64 + db.Model(&models.Promotion{}). + Where("status = ? AND (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at >= ?)", enums.StatusOk, dayEnd, dayStart). + Count(&activePromotionCount) + + highIntentLeads := s.listReportHighIntentLeads(db, dayStart, dayEnd, validLeadStatuses) + unassignedPriorityLeadCount := s.countReportUnassignedPriorityLeads(db, dayEnd) + overdueFollowUpCount, todayFollowUpCount, unscheduledHotLeads := s.countReportFollowUpRisks(db, dayStart, dayEnd) + overdueAppointmentCount, todayAppointmentCount, unscheduledAppointmentCount := s.countReportAppointmentRisks(db, dayStart, dayEnd) + pendingAfterSalesTicketCount, todayAfterSalesTicketCount, todayHandledAfterSalesTicketCount := s.countReportAfterSalesTicketRisks(db, dayStart, dayEnd) + aiFeedbackCount, aiFeedbackLikeCount, aiFeedbackNegativeCount := s.countReportAIFeedbacks(db, dayStart, dayEnd) + priorityFollowUps := s.listReportPriorityFollowUps(db, dayStart, dayEnd) + afterSalesTickets := s.listReportAfterSalesTickets(db) + recentNegativeAIFeedbacks := s.listReportRecentNegativeAIFeedbacks(db, dayStart, dayEnd) + pendingFAQDraftCount, pendingFAQDrafts := s.listReportPendingFAQDrafts(db) + + report := response.DashboardDailyBusinessReportResponse{ + ReportDate: reportDate, + ConversationCount: conversationCount, + AIReplyCount: aiReplyCount, + HandoffCount: handoffCount, + LeadCount: leadCount, + LeadConversionRate: calcRate(leadCount, conversationCount), + HighIntentCount: highIntentCount, + AppointmentCount: appointmentCount, + ConvertedCount: convertedCount, + UnresolvedCount: unresolvedCount, + UnassignedPriorityLeadCount: unassignedPriorityLeadCount, + OverdueFollowUpCount: overdueFollowUpCount, + TodayFollowUpCount: todayFollowUpCount, + UnscheduledHotLeads: unscheduledHotLeads, + OverdueAppointmentCount: overdueAppointmentCount, + TodayAppointmentCount: todayAppointmentCount, + UnscheduledAppointmentCount: unscheduledAppointmentCount, + PendingAfterSalesTicketCount: pendingAfterSalesTicketCount, + TodayAfterSalesTicketCount: todayAfterSalesTicketCount, + TodayHandledAfterSalesTicketCount: todayHandledAfterSalesTicketCount, + AIFeedbackCount: aiFeedbackCount, + AIFeedbackLikeCount: aiFeedbackLikeCount, + AIFeedbackNegativeCount: aiFeedbackNegativeCount, + AIFeedbackNegativeRate: calcRate(aiFeedbackNegativeCount, aiFeedbackCount), + ActiveProductCount: activeProductCount, + ActivePromotionCount: activePromotionCount, + TopLeadProducts: buildTopLeadProducts(db, dayStart, validLeadStatuses), + TopQuestions: buildTopKnowledgeQuestions(db, dayStart, dayEnd, nil), + UnansweredQuestions: buildTopKnowledgeQuestions(db, dayStart, dayEnd, []int{2, 3, 4}), + TopAIFeedbackReasons: buildTopAIFeedbackReasons(db, dayStart, dayEnd), + RecentNegativeAIFeedbacks: recentNegativeAIFeedbacks, + PendingFAQDraftCount: pendingFAQDraftCount, + PendingFAQDrafts: pendingFAQDrafts, + HighIntentLeads: highIntentLeads, + PriorityFollowUps: priorityFollowUps, + AfterSalesTickets: afterSalesTickets, + } + report.Summary = buildDailyBusinessReportSummary(locale, report) + report.Highlights = buildDailyBusinessReportHighlights(locale, report) + report.FollowUpSuggestions = buildDailyBusinessReportFollowUps(locale, report) + report.KnowledgeSuggestions = buildDailyBusinessReportKnowledgeSuggestions(locale, report) + return report +} + +func (s *dashboardService) SendDailyBusinessReportWebhook(dateValue string, locale string, operatorID int64) (response.DashboardDailyBusinessReportPushResponse, error) { + locale = i18nx.NormalizeLocale(locale) + report := s.GetDailyBusinessReport(dateValue, locale) + title := fmt.Sprintf("AI 数字店长经营日报 %s", report.ReportDate) + body := buildDailyBusinessReportWebhookText(report) + cfg := config.Current().Notify.DailyReport + ret := response.DashboardDailyBusinessReportPushResponse{ + ReportDate: report.ReportDate, + GeneratedAt: time.Now().Format("2006-01-02 15:04:05"), + WebhookEnabled: WebhookNotifyService.Enabled(), + DailyEnabled: cfg.Enabled, + Title: title, + Message: "日报已生成。", + WebhookEventType: "daily_business_report", + } + if !WebhookNotifyService.Enabled() { + ret.Message = "外部 Webhook 未启用,日报未发送。" + return ret, nil + } + if err := WebhookNotifyService.SendText(ret.WebhookEventType, title, body, map[string]any{ + "reportDate": report.ReportDate, + "conversationCount": report.ConversationCount, + "leadCount": report.LeadCount, + "convertedCount": report.ConvertedCount, + "leadConversionRate": report.LeadConversionRate, + "overdueFollowUpCount": report.OverdueFollowUpCount, + "todayFollowUpCount": report.TodayFollowUpCount, + "unassignedPriorityLeadCount": report.UnassignedPriorityLeadCount, + "pendingAfterSalesTicketCount": report.PendingAfterSalesTicketCount, + "todayAfterSalesTicketCount": report.TodayAfterSalesTicketCount, + "todayHandledAfterSalesTicketCount": report.TodayHandledAfterSalesTicketCount, + "aiFeedbackNegativeCount": report.AIFeedbackNegativeCount, + "pendingFaqDraftCount": report.PendingFAQDraftCount, + "operatorId": operatorID, + }); err != nil { + ret.Message = "日报发送失败。" + return ret, err + } + ret.Sent = true + ret.Message = "日报已发送到外部 Webhook。" + return ret, nil +} + +func (s *dashboardService) SendScheduledDailyBusinessReportWebhook(dateValue string, locale string) (response.DashboardDailyBusinessReportPushResponse, error) { + reportDate, _, _ := resolveReportDay(dateValue, time.Now()) + dailyCfg := config.Current().Notify.DailyReport + if !dailyCfg.AllowDuplicate && s.wasScheduledDailyBusinessReportSent(reportDate) { + return response.DashboardDailyBusinessReportPushResponse{ + ReportDate: reportDate, + GeneratedAt: time.Now().Format("2006-01-02 15:04:05"), + WebhookEnabled: WebhookNotifyService.Enabled(), + DailyEnabled: dailyCfg.Enabled, + Sent: false, + Title: fmt.Sprintf("AI 数字店长经营日报 %s", reportDate), + Message: "定时日报已发送过,已跳过重复推送。", + WebhookEventType: "daily_business_report", + }, nil + } + resp, err := s.SendDailyBusinessReportWebhook(reportDate, locale, 0) + if err != nil { + return resp, err + } + if resp.Sent && !dailyCfg.AllowDuplicate { + if err := s.markScheduledDailyBusinessReportSent(resp.ReportDate); err != nil { + return resp, err + } + } + return resp, nil +} + +func (s *dashboardService) wasScheduledDailyBusinessReportSent(reportDate string) bool { + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", dashboardDailyReportLastSentConfigKey) + return item != nil && strings.TrimSpace(item.ConfigValue) == strings.TrimSpace(reportDate) +} + +func (s *dashboardService) markScheduledDailyBusinessReportSent(reportDate string) error { + now := time.Now() + reportDate = strings.TrimSpace(reportDate) + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", dashboardDailyReportLastSentConfigKey) + if item == nil { + return repositories.SystemConfigRepository.Create(sqls.DB(), &models.SystemConfig{ + ConfigKey: dashboardDailyReportLastSentConfigKey, + ConfigValue: reportDate, + GroupCode: "dashboard", + Title: "最近一次定时经营日报日期", + Description: "用于避免定时任务重复推送同一天的老板经营日报。", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now, CreateUserName: "system", UpdateUserName: "system"}, + }) + } + return repositories.SystemConfigRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "config_value": reportDate, + "updated_at": now, + "update_user_name": "system", + }) +} + +func (s *dashboardService) GetAIQualityReport(rangeValue string, locale string) response.DashboardAIQualityReportResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + normalizedRange, days := normalizeDashboardRange(rangeValue) + dayEnd := startOfDay(now).AddDate(0, 0, 1) + dayStart := dayEnd.AddDate(0, 0, -days) + db := sqls.DB() + + var retrieveTotal int64 + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&retrieveTotal) + + var retrieveHitTotal int64 + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("hit_count > 0"). + Count(&retrieveHitTotal) + + var noAnswerCount int64 + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("answer_status = ?", int(enums.KnowledgeAnswerStatusNoAnswer)). + Count(&noAnswerCount) + + var fallbackCount int64 + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("answer_status = ?", int(enums.KnowledgeAnswerStatusFallback)). + Count(&fallbackCount) + + var blockedCount int64 + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("answer_status = ?", int(enums.KnowledgeAnswerStatusBlocked)). + Count(&blockedCount) + + feedbackCount, _, negativeFeedbackCount := s.countReportAIFeedbacks(db, dayStart, dayEnd) + pendingFAQDraftCount, pendingFAQDrafts := s.listReportPendingFAQDrafts(db) + recentNegativeFeedbacks := s.listReportRecentNegativeAIFeedbacks(db, dayStart, dayEnd) + topQuestions := buildTopKnowledgeQuestions(db, dayStart, dayEnd, nil) + unansweredQuestions := buildTopKnowledgeQuestions(db, dayStart, dayEnd, []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + }) + pendingQuestionGroups := s.listPendingQuestionGroups(db, dayStart, dayEnd) + topNegativeReasons := buildTopAIFeedbackReasons(db, dayStart, dayEnd) + recentRiskAnswerSamples := s.listRecentRiskAnswerSamples(db, dayStart, dayEnd) + riskAnswerCount := noAnswerCount + fallbackCount + blockedCount + todos := buildAIQualityTodos(noAnswerCount, fallbackCount, blockedCount, negativeFeedbackCount, pendingFAQDraftCount) + + report := response.DashboardAIQualityReportResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + StartDate: dayStart.Format("2006-01-02"), + EndDate: dayEnd.Add(-time.Second).Format("2006-01-02"), + RetrieveTotal: retrieveTotal, + RetrieveHitTotal: retrieveHitTotal, + RetrieveHitRate: calcRate(retrieveHitTotal, retrieveTotal), + NoAnswerCount: noAnswerCount, + FallbackCount: fallbackCount, + BlockedCount: blockedCount, + RiskAnswerCount: riskAnswerCount, + NegativeFeedbackCount: negativeFeedbackCount, + FeedbackCount: feedbackCount, + NegativeFeedbackRate: calcRate(negativeFeedbackCount, feedbackCount), + PendingFAQDraftCount: pendingFAQDraftCount, + TodoTotal: int64(len(todos)), + Todos: todos, + TopQuestions: topQuestions, + UnansweredQuestions: unansweredQuestions, + TopNegativeReasons: topNegativeReasons, + PendingQuestionGroups: pendingQuestionGroups, + RecentNegativeFeedbacks: recentNegativeFeedbacks, + PendingFAQDrafts: pendingFAQDrafts, + RecentRiskAnswerSamples: recentRiskAnswerSamples, + } + report.KnowledgeSuggestions = buildAIQualityKnowledgeSuggestions(locale, report) + return report +} + +func (s *dashboardService) GetSalesFunnelReport(rangeValue string, locale string) response.DashboardSalesFunnelReportResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + normalizedRange, days := normalizeDashboardRange(rangeValue) + dayEnd := startOfDay(now).AddDate(0, 0, 1) + dayStart := dayEnd.AddDate(0, 0, -days) + todayStart := startOfDay(now) + tomorrowStart := todayStart.AddDate(0, 0, 1) + db := sqls.DB() + + var conversationTotal int64 + db.Model(&models.Conversation{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&conversationTotal) + + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("status <> ?", enums.SalesLeadStatusClosed). + Find(&leads) + + leadTotal := int64(len(leads)) + highIntentTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.IntentLevel == enums.SalesLeadIntentHigh + }) + appointmentTotal := countSalesFunnelLeads(leads, salesFunnelLeadHasAppointment) + visitedTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.Status == enums.SalesLeadStatusVisited || item.Status == enums.SalesLeadStatusConverted + }) + readyToBuyTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.BuyingStage == enums.SalesLeadStageReadyToBuy || item.Status == enums.SalesLeadStatusConverted + }) + convertedTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.Status == enums.SalesLeadStatusConverted + }) + invalidTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.Status == enums.SalesLeadStatusInvalid + }) + invalidReasons := buildSalesFunnelInvalidReasons(leads, 5) + unassignedTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.OwnerUserID == 0 && (item.Status == enums.SalesLeadStatusNew || item.Status == enums.SalesLeadStatusFollowing) + }) + overdueFollowUpTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return (item.Status == enums.SalesLeadStatusNew || item.Status == enums.SalesLeadStatusFollowing) && + item.NextFollowUpAt != nil && item.NextFollowUpAt.Before(todayStart) + }) + steps := buildSalesFunnelSteps(conversationTotal, leadTotal, highIntentTotal, appointmentTotal, visitedTotal, readyToBuyTotal, convertedTotal) + advisorStats := buildAdvisorEfficiencyStats(db, leads, todayStart, tomorrowStart) + + report := response.DashboardSalesFunnelReportResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + StartDate: dayStart.Format("2006-01-02"), + EndDate: dayEnd.Add(-time.Second).Format("2006-01-02"), + ConversationTotal: conversationTotal, + LeadTotal: leadTotal, + LeadConversionRate: calcRate(leadTotal, conversationTotal), + ClosedConversionRate: calcRate(convertedTotal, leadTotal), + AppointmentTotal: appointmentTotal, + VisitedTotal: visitedTotal, + ConvertedTotal: convertedTotal, + InvalidTotal: invalidTotal, + UnassignedTotal: unassignedTotal, + OverdueFollowUpTotal: overdueFollowUpTotal, + InvalidReasons: invalidReasons, + Steps: steps, + AdvisorStats: advisorStats, + } + report.Suggestions = buildSalesFunnelSuggestions(locale, report) + return report +} + +func (s *dashboardService) GetBusinessTrendReport(rangeValue string, locale string) response.DashboardBusinessTrendReportResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + normalizedRange, days := normalizeDashboardRange(rangeValue) + dayEnd := startOfDay(now).AddDate(0, 0, 1) + dayStart := dayEnd.AddDate(0, 0, -days) + todayStart := startOfDay(now) + tomorrowStart := todayStart.AddDate(0, 0, 1) + db := sqls.DB() + + var conversationTotal int64 + db.Model(&models.Conversation{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&conversationTotal) + + var handoffTotal int64 + db.Model(&models.Conversation{}). + Where("handoff_at >= ? AND handoff_at < ?", dayStart, dayEnd). + Count(&handoffTotal) + + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("status <> ?", enums.SalesLeadStatusClosed). + Find(&leads) + + leadTotal := int64(len(leads)) + highIntentTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.IntentLevel == enums.SalesLeadIntentHigh + }) + appointmentTotal := countSalesFunnelLeads(leads, salesFunnelLeadHasAppointment) + visitedTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.Status == enums.SalesLeadStatusVisited || item.Status == enums.SalesLeadStatusConverted + }) + convertedTotal := countSalesFunnelLeads(leads, func(item models.SalesLead) bool { + return item.Status == enums.SalesLeadStatusConverted + }) + _, _, negativeFeedbackTotal := s.countReportAIFeedbacks(db, dayStart, dayEnd) + pendingFAQDraftCount, _ := s.listReportPendingFAQDrafts(db) + validLeadStatuses := []enums.SalesLeadStatus{ + enums.SalesLeadStatusNew, + enums.SalesLeadStatusFollowing, + enums.SalesLeadStatusVisited, + enums.SalesLeadStatusConverted, + } + + report := response.DashboardBusinessTrendReportResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + StartDate: dayStart.Format("2006-01-02"), + EndDate: dayEnd.Add(-time.Second).Format("2006-01-02"), + ConversationTotal: conversationTotal, + LeadTotal: leadTotal, + LeadConversionRate: calcRate(leadTotal, conversationTotal), + HighIntentTotal: highIntentTotal, + AppointmentTotal: appointmentTotal, + VisitedTotal: visitedTotal, + ConvertedTotal: convertedTotal, + HandoffTotal: handoffTotal, + NegativeFeedbackTotal: negativeFeedbackTotal, + PendingFAQDraftCount: pendingFAQDraftCount, + Series: buildBusinessTrendSeries(db, dayStart, days), + TopProducts: buildTopLeadProductsInRange(db, dayStart, dayEnd, validLeadStatuses), + TopChannels: buildTopLeadChannels(db, dayStart, dayEnd), + TopQuestions: buildTopKnowledgeQuestions(db, dayStart, dayEnd, nil), + TopUnansweredQuestions: buildTopKnowledgeQuestions(db, dayStart, dayEnd, []int{2, 3, 4}), + TopNegativeReasons: buildTopAIFeedbackReasons(db, dayStart, dayEnd), + AdvisorStats: buildAdvisorEfficiencyStats(db, leads, todayStart, tomorrowStart), + } + report.Suggestions = buildBusinessTrendSuggestions(locale, report) + report.ReportMarkdown = buildBusinessTrendReportMarkdown(report) + return report +} + +func (s *dashboardService) GetABTestReport(rangeValue string, locale string) response.DashboardABTestReportResponse { + locale = i18nx.NormalizeLocale(locale) + now := time.Now() + normalizedRange, days := normalizeDashboardRange(rangeValue) + dayEnd := startOfDay(now).AddDate(0, 0, 1) + dayStart := dayEnd.AddDate(0, 0, -days) + db := sqls.DB() + + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("status <> ?", enums.SalesLeadStatusClosed). + Find(&leads) + + feedbackTotal, _, negativeFeedbackTotal := s.countReportAIFeedbacks(db, dayStart, dayEnd) + variants := buildABTestVariants(leads) + report := response.DashboardABTestReportResponse{ + Range: normalizedRange, + GeneratedAt: now.Format("2006-01-02 15:04:05"), + StartDate: dayStart.Format("2006-01-02"), + EndDate: dayEnd.Add(-time.Second).Format("2006-01-02"), + VariantTotal: int64(len(variants)), + LeadTotal: int64(len(leads)), + FeedbackTotal: feedbackTotal, + NegativeFeedbackTotal: negativeFeedbackTotal, + NegativeFeedbackRate: calcRate(negativeFeedbackTotal, feedbackTotal), + Variants: variants, + } + report.Suggestions = buildABTestSuggestions(locale, report) + return report +} + +func countSalesFunnelLeads(leads []models.SalesLead, match func(models.SalesLead) bool) int64 { + var count int64 + for _, item := range leads { + if match(item) { + count++ + } + } + return count +} + +func salesFunnelLeadHasAppointment(item models.SalesLead) bool { + return item.BuyingStage == enums.SalesLeadStageAppointment || + item.BuyingStage == enums.SalesLeadStageReadyToBuy || + item.Status == enums.SalesLeadStatusVisited || + item.Status == enums.SalesLeadStatusConverted || + item.AppointmentAt != nil || + strings.TrimSpace(item.AppointmentTimeText) != "" || + strings.TrimSpace(item.AppointmentStore) != "" +} + +func buildSalesFunnelSteps(conversations, leads, highIntent, appointment, visited, readyToBuy, converted int64) []response.DashboardSalesFunnelStep { + items := []struct { + key string + label string + count int64 + actionHref string + }{ + {key: "consultation", label: "咨询", count: conversations, actionHref: "/dashboard/conversations"}, + {key: "lead", label: "留资", count: leads, actionHref: "/dashboard/sales-leads"}, + {key: "high_intent", label: "高意向", count: highIntent, actionHref: "/dashboard/sales-leads?intent=high"}, + {key: "appointment", label: "预约", count: appointment, actionHref: "/dashboard/sales-leads?appointmentStatus=upcoming"}, + {key: "visited", label: "到店", count: visited, actionHref: "/dashboard/sales-leads?status=visited"}, + {key: "ready_to_buy", label: "准成交", count: readyToBuy, actionHref: "/dashboard/sales-leads?taskView=high_intent"}, + {key: "converted", label: "成交", count: converted, actionHref: "/dashboard/sales-leads?status=converted"}, + } + ret := make([]response.DashboardSalesFunnelStep, 0, len(items)) + base := conversations + for i, item := range items { + previous := item.count + if i > 0 { + previous = items[i-1].count + } + dropOff := previous - item.count + if dropOff < 0 { + dropOff = 0 + } + ret = append(ret, response.DashboardSalesFunnelStep{ + Key: item.key, + Label: item.label, + Count: item.count, + Rate: calcRate(item.count, base), + DropOffCount: dropOff, + DropOffRate: calcRate(dropOff, previous), + ActionHref: item.actionHref, + }) + } + return ret +} + +func buildAdvisorEfficiencyStats(db *gorm.DB, leads []models.SalesLead, todayStart, tomorrowStart time.Time) []response.DashboardAdvisorEfficiency { + type counter struct { + response.DashboardAdvisorEfficiency + firstFollowUpTotalMinutes int64 + firstFollowUpLeadCount int64 + invalidReasonCounts map[string]int64 + } + counters := map[int64]*counter{} + leadIDs := make([]int64, 0, len(leads)) + leadByID := map[int64]models.SalesLead{} + ownerIDs := make([]int64, 0) + ownerSeen := map[int64]bool{} + for _, lead := range leads { + leadIDs = append(leadIDs, lead.ID) + leadByID[lead.ID] = lead + ownerID := lead.OwnerUserID + if _, ok := counters[ownerID]; !ok { + counters[ownerID] = &counter{ + DashboardAdvisorEfficiency: response.DashboardAdvisorEfficiency{OwnerUserID: ownerID}, + invalidReasonCounts: map[string]int64{}, + } + } + if ownerID > 0 && !ownerSeen[ownerID] { + ownerSeen[ownerID] = true + ownerIDs = append(ownerIDs, ownerID) + } + current := counters[ownerID] + current.AssignedLeadCount++ + if lead.Status == enums.SalesLeadStatusConverted { + current.ConvertedLeadCount++ + } + if lead.Status == enums.SalesLeadStatusInvalid { + current.InvalidLeadCount++ + current.invalidReasonCounts[inferSalesFunnelInvalidReason(lead)]++ + } + if (lead.Status == enums.SalesLeadStatusNew || lead.Status == enums.SalesLeadStatusFollowing) && lead.NextFollowUpAt != nil { + if lead.NextFollowUpAt.Before(todayStart) { + current.OverdueFollowUpCount++ + } else if lead.NextFollowUpAt.Before(tomorrowStart) { + current.TodayFollowUpCount++ + } + } + } + ownerNames := dashboardOwnerNameMap(db, ownerIDs) + followUps := make([]models.LeadFollowUp, 0) + if len(leadIDs) > 0 { + db.Model(&models.LeadFollowUp{}). + Where("lead_id IN ?", leadIDs). + Order("created_at ASC, id ASC"). + Find(&followUps) + } + firstFollowUpSeen := map[int64]bool{} + for _, followUp := range followUps { + lead := leadByID[followUp.LeadID] + ownerID := lead.OwnerUserID + current := counters[ownerID] + if current == nil { + continue + } + current.FollowUpCount++ + if firstFollowUpSeen[followUp.LeadID] || followUp.CreatedAt.Before(lead.CreatedAt) { + continue + } + firstFollowUpSeen[followUp.LeadID] = true + current.firstFollowUpLeadCount++ + current.firstFollowUpTotalMinutes += int64(followUp.CreatedAt.Sub(lead.CreatedAt).Minutes()) + } + ret := make([]response.DashboardAdvisorEfficiency, 0, len(counters)) + for ownerID, item := range counters { + if ownerID == 0 { + item.OwnerUserName = "未分配" + } else { + item.OwnerUserName = ownerNames[ownerID] + if item.OwnerUserName == "" { + item.OwnerUserName = fmt.Sprintf("用户 #%d", ownerID) + } + } + item.ConversionRate = calcRate(item.ConvertedLeadCount, item.AssignedLeadCount) + item.InvalidRate = calcRate(item.InvalidLeadCount, item.AssignedLeadCount) + if item.firstFollowUpLeadCount > 0 { + item.AverageFirstFollowUpMinutes = item.firstFollowUpTotalMinutes / item.firstFollowUpLeadCount + } + item.InvalidReasons = dashboardTopItems(item.invalidReasonCounts, 3) + ret = append(ret, item.DashboardAdvisorEfficiency) + } + sort.Slice(ret, func(i, j int) bool { + if ret[i].OwnerUserID == 0 { + return false + } + if ret[j].OwnerUserID == 0 { + return true + } + if ret[i].ConvertedLeadCount == ret[j].ConvertedLeadCount { + if ret[i].OverdueFollowUpCount == ret[j].OverdueFollowUpCount { + return ret[i].AssignedLeadCount > ret[j].AssignedLeadCount + } + return ret[i].OverdueFollowUpCount < ret[j].OverdueFollowUpCount + } + return ret[i].ConvertedLeadCount > ret[j].ConvertedLeadCount + }) + if len(ret) > 8 { + ret = ret[:8] + } + return ret +} + +func buildSalesFunnelInvalidReasons(leads []models.SalesLead, limit int) []response.DashboardTopItemResponse { + counts := map[string]int64{} + for _, lead := range leads { + if lead.Status != enums.SalesLeadStatusInvalid { + continue + } + counts[inferSalesFunnelInvalidReason(lead)]++ + } + return dashboardTopItems(counts, limit) +} + +func inferSalesFunnelInvalidReason(lead models.SalesLead) string { + text := strings.ToLower(strings.TrimSpace(strings.Join([]string{ + lead.Remark, + lead.DemandSummary, + lead.MergeReason, + lead.SourceChannel, + }, " "))) + switch { + case containsAnyLeadText(text, "预算", "budget", "太贵", "贵", "价格", "没钱", "超预算"): + return "预算不匹配" + case containsAnyLeadText(text, "联系不上", "空号", "停机", "无人接", "不接", "电话错误", "号码错误", "手机号错"): + return "联系不上" + case containsAnyLeadText(text, "重复", "duplicate", "已存在", "归并", "merge"): + return "重复线索" + case containsAnyLeadText(text, "售后", "投诉", "退款", "退货", "维修", "质保", "保修", "换货"): + return "售后/投诉" + case containsAnyLeadText(text, "不需要", "暂不", "不考虑", "已购买", "买过", "只是看看", "无需求", "没需求"): + return "暂无需求" + case containsAnyLeadText(text, "同行", "广告", "刷单", "测试", "无效流量", "垃圾", "机器人"): + return "渠道质量问题" + default: + return "其他原因" + } +} + +func dashboardOwnerNameMap(db *gorm.DB, ownerIDs []int64) map[int64]string { + ret := map[int64]string{} + if len(ownerIDs) == 0 { + return ret + } + var users []models.User + db.Model(&models.User{}). + Where("id IN ?", ownerIDs). + Find(&users) + for _, user := range users { + name := strings.TrimSpace(user.Nickname) + if name == "" { + name = strings.TrimSpace(user.Username) + } + ret[user.ID] = name + } + return ret +} + +func buildBusinessTrendSeries(db *gorm.DB, dayStart time.Time, days int) []response.DashboardBusinessTrendItem { + if days <= 0 { + return nil + } + series := make([]response.DashboardBusinessTrendItem, 0, days) + for i := 0; i < days; i++ { + currentStart := dayStart.AddDate(0, 0, i) + currentEnd := currentStart.AddDate(0, 0, 1) + item := response.DashboardBusinessTrendItem{Date: currentStart.Format("2006-01-02")} + db.Model(&models.Conversation{}). + Where("created_at >= ? AND created_at < ?", currentStart, currentEnd). + Count(&item.ConversationCount) + db.Model(&models.Conversation{}). + Where("handoff_at >= ? AND handoff_at < ?", currentStart, currentEnd). + Count(&item.HandoffCount) + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status <> ?", currentStart, currentEnd, enums.SalesLeadStatusClosed). + Count(&item.LeadCount) + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status <> ? AND intent_level = ?", currentStart, currentEnd, enums.SalesLeadStatusClosed, enums.SalesLeadIntentHigh). + Count(&item.HighIntentCount) + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status <> ? AND (buying_stage IN ? OR appointment_at IS NOT NULL OR appointment_time_text <> '' OR appointment_store <> '')", + currentStart, currentEnd, enums.SalesLeadStatusClosed, []enums.SalesLeadStage{ + enums.SalesLeadStageAppointment, + enums.SalesLeadStageReadyToBuy, + }). + Count(&item.AppointmentCount) + db.Model(&models.SalesLead{}). + Where("updated_at >= ? AND updated_at < ? AND status IN ?", currentStart, currentEnd, []enums.SalesLeadStatus{ + enums.SalesLeadStatusVisited, + enums.SalesLeadStatusConverted, + }). + Count(&item.VisitedCount) + db.Model(&models.SalesLead{}). + Where("updated_at >= ? AND updated_at < ? AND status = ?", currentStart, currentEnd, enums.SalesLeadStatusConverted). + Count(&item.ConvertedCount) + db.Model(&models.KnowledgeFeedback{}). + Where("created_at >= ? AND created_at < ? AND feedback_type <> ?", currentStart, currentEnd, int(enums.KnowledgeFeedbackTypeLike)). + Count(&item.NegativeFeedbackCount) + series = append(series, item) + } + return series +} + +func buildTopLeadProductsInRange(db *gorm.DB, dayStart, dayEnd time.Time, validStatuses []enums.SalesLeadStatus) []response.DashboardTopItemResponse { + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Select("interested_products"). + Where("created_at >= ? AND created_at < ? AND status IN ? AND interested_products <> ''", dayStart, dayEnd, validStatuses). + Find(&leads) + + productCounts := make(map[string]int64) + for _, lead := range leads { + for _, name := range splitLeadProductNames(lead.InterestedProducts) { + productCounts[name]++ + } + } + return dashboardTopItems(productCounts, 5) +} + +func buildTopLeadChannels(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardTopItemResponse { + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Select("source_channel"). + Where("created_at >= ? AND created_at < ? AND status <> ?", dayStart, dayEnd, enums.SalesLeadStatusClosed). + Find(&leads) + + counts := make(map[string]int64) + for _, lead := range leads { + name := strings.TrimSpace(lead.SourceChannel) + if name == "" { + name = "未标记渠道" + } + counts[name]++ + } + return dashboardTopItems(counts, 5) +} + +func dashboardTopItems(counts map[string]int64, limit int) []response.DashboardTopItemResponse { + items := make([]response.DashboardTopItemResponse, 0, len(counts)) + for name, count := range counts { + items = append(items, response.DashboardTopItemResponse{Name: name, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count == items[j].Count { + return items[i].Name < items[j].Name + } + return items[i].Count > items[j].Count + }) + if limit > 0 && len(items) > limit { + items = items[:limit] + } + return items +} + +func buildABTestVariants(leads []models.SalesLead) []response.DashboardABTestVariantResult { + type counter struct { + response.DashboardABTestVariantResult + productCounts map[string]int64 + } + counters := map[string]*counter{} + for _, lead := range leads { + code := strings.TrimSpace(lead.SourceChannel) + if code == "" { + code = "untracked" + } + current := counters[code] + if current == nil { + current = &counter{ + DashboardABTestVariantResult: response.DashboardABTestVariantResult{ + VariantCode: code, + VariantName: abVariantDisplayName(code), + }, + productCounts: map[string]int64{}, + } + counters[code] = current + } + current.LeadCount++ + if lead.IntentLevel == enums.SalesLeadIntentHigh { + current.HighIntentCount++ + } + if salesFunnelLeadHasAppointment(lead) { + current.AppointmentCount++ + } + if lead.Status == enums.SalesLeadStatusVisited || lead.Status == enums.SalesLeadStatusConverted { + current.VisitedCount++ + } + if lead.Status == enums.SalesLeadStatusConverted { + current.ConvertedCount++ + } + if lead.Status == enums.SalesLeadStatusInvalid { + current.InvalidCount++ + } + for _, product := range splitLeadProductNames(lead.InterestedProducts) { + current.productCounts[product]++ + } + } + ret := make([]response.DashboardABTestVariantResult, 0, len(counters)) + for _, item := range counters { + item.HighIntentRate = calcRate(item.HighIntentCount, item.LeadCount) + item.AppointmentRate = calcRate(item.AppointmentCount, item.LeadCount) + item.VisitRate = calcRate(item.VisitedCount, item.LeadCount) + item.ConversionRate = calcRate(item.ConvertedCount, item.LeadCount) + item.InvalidRate = calcRate(item.InvalidCount, item.LeadCount) + if topProducts := dashboardTopItems(item.productCounts, 1); len(topProducts) > 0 { + item.TopProduct = topProducts[0].Name + } + item.QualityRiskLevel, item.QualityRiskReason = buildABVariantQualityRisk(item.DashboardABTestVariantResult) + item.RecommendedAction = buildABVariantRecommendedAction(item.DashboardABTestVariantResult) + ret = append(ret, item.DashboardABTestVariantResult) + } + sort.Slice(ret, func(i, j int) bool { + if ret[i].ConvertedCount == ret[j].ConvertedCount { + if ret[i].AppointmentCount == ret[j].AppointmentCount { + return ret[i].LeadCount > ret[j].LeadCount + } + return ret[i].AppointmentCount > ret[j].AppointmentCount + } + return ret[i].ConvertedCount > ret[j].ConvertedCount + }) + if len(ret) > 8 { + ret = ret[:8] + } + return ret +} + +func abVariantDisplayName(code string) string { + code = strings.TrimSpace(code) + if code == "" || code == "untracked" { + return "未标记版本" + } + replacer := strings.NewReplacer("_", " ", "-", " ", "ab:", "", "AB:", "") + return strings.TrimSpace(replacer.Replace(code)) +} + +func buildABVariantQualityRisk(item response.DashboardABTestVariantResult) (string, string) { + if item.LeadCount < 5 && item.InvalidRate >= 50 { + return "medium", "样本偏少但无效率已偏高,先复查入口人群和留资判断。" + } + if item.LeadCount < 5 { + return "sample_low", "样本不足,先继续观察,避免过早判断话术优劣。" + } + if item.InvalidRate >= 30 { + return "high", "无效率偏高,可能吸引了不匹配人群或承诺口径过强。" + } + if item.AppointmentRate >= 30 && item.VisitRate < 15 { + return "medium", "预约到店断层明显,需要加强到店确认、路线和利益点提醒。" + } + if item.HighIntentRate >= 40 && item.AppointmentRate < 20 { + return "medium", "高意向未有效转预约,留资后的邀约承接需要优化。" + } + if item.ConversionRate >= 15 || item.AppointmentRate >= 35 { + return "low", "转化表现较稳,可在继续观察质量反馈的前提下扩大使用。" + } + return "neutral", "暂无明显风险,建议继续和头部版本做周度对比。" +} + +func buildABVariantRecommendedAction(item response.DashboardABTestVariantResult) string { + if item.LeadCount < 5 { + return "样本偏少,继续积累后再判断。" + } + if item.QualityRiskLevel == "high" { + return "质量风险偏高,先暂停放量,复盘口径、人群和无效线索来源。" + } + if item.AppointmentRate >= 30 && item.VisitRate < 15 { + return "预约意向不错,但到店承接偏弱,优化到店前确认和路线提醒。" + } + if item.ConversionRate >= 15 || item.AppointmentRate >= 35 { + return "表现较好,可扩大投放或复制话术。" + } + if item.HighIntentRate >= 40 && item.AppointmentRate < 20 { + return "能吸引高意向,但预约承接偏弱,优化预约引导。" + } + if item.InvalidRate >= 30 { + return "无效率偏高,检查入口人群、承诺口径和留资判断。" + } + return "表现中性,建议和头部版本对比开场白与权益表达。" +} + +func buildSalesFunnelSuggestions(locale string, report response.DashboardSalesFunnelReportResponse) []string { + suggestions := make([]string, 0, 5) + if report.ConversationTotal > 0 && report.LeadConversionRate < 20 { + suggestions = append(suggestions, fmt.Sprintf("咨询到留资转化率 %.1f%%,建议优化开场白、优惠权益和留资引导。", report.LeadConversionRate)) + } + if report.LeadTotal > 0 && report.ClosedConversionRate < 10 { + suggestions = append(suggestions, fmt.Sprintf("留资到成交转化率 %.1f%%,建议复盘高意向线索跟进节奏和报价确认。", report.ClosedConversionRate)) + } + if report.AppointmentTotal > 0 && calcRate(report.VisitedTotal, report.AppointmentTotal) < 50 { + suggestions = append(suggestions, fmt.Sprintf("预约到店率 %.1f%%,建议提前一天确认时间、门店和体验产品,并及时把已到店客户标记出来。", calcRate(report.VisitedTotal, report.AppointmentTotal))) + } + if report.UnassignedTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("还有 %d 条线索未分配,建议使用线索页认领或调整自动分配规则。", report.UnassignedTotal)) + } + if report.OverdueFollowUpTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条线索已逾期未跟进,优先处理会直接影响预约和成交。", report.OverdueFollowUpTotal)) + } + if report.InvalidTotal > 0 && report.InvalidTotal >= report.ConvertedTotal { + suggestions = append(suggestions, fmt.Sprintf("无效线索 %d 条,已接近或超过成交数,建议检查渠道和 AI 留资判断口径。", report.InvalidTotal)) + } + if len(report.Steps) >= 4 { + maxDrop := report.Steps[1] + for _, step := range report.Steps[2:] { + if step.DropOffCount > maxDrop.DropOffCount { + maxDrop = step + } + } + if maxDrop.DropOffCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("当前最大流失发生在「%s」前一环节,流失 %d 个,建议针对该环节做话术和跟进 SOP。", maxDrop.Label, maxDrop.DropOffCount)) + } + } + if len(suggestions) == 0 { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions = append(suggestions, "The sales funnel looks healthy for the selected period. Keep monitoring unassigned and overdue leads.") + } else { + suggestions = append(suggestions, "当前周期线索漏斗较健康,继续关注未分配和逾期跟进线索即可。") + } + } + return suggestions +} + +func buildBusinessTrendSuggestions(locale string, report response.DashboardBusinessTrendReportResponse) []string { + suggestions := make([]string, 0, 6) + if report.ConversationTotal == 0 { + suggestions = append(suggestions, "当前周期暂无咨询数据,建议先检查渠道入口、嵌入脚本和门店投放链接是否正常。") + } + if report.ConversationTotal > 0 && report.LeadConversionRate < 20 { + suggestions = append(suggestions, fmt.Sprintf("咨询到留资转化率 %.1f%%,建议优化欢迎语、优惠权益和留资时机。", report.LeadConversionRate)) + } + if report.LeadTotal > 0 && calcRate(report.AppointmentTotal, report.LeadTotal) < 25 { + suggestions = append(suggestions, fmt.Sprintf("留资到预约占比 %.1f%%,建议让 AI 更早询问到店时间、门店位置和预算区间。", calcRate(report.AppointmentTotal, report.LeadTotal))) + } + if report.AppointmentTotal > 0 && calcRate(report.VisitedTotal, report.AppointmentTotal) < 50 { + suggestions = append(suggestions, fmt.Sprintf("预约到店率 %.1f%%,建议把到店前确认、路线提醒和体验产品准备加入顾问 SOP。", calcRate(report.VisitedTotal, report.AppointmentTotal))) + } + if report.NegativeFeedbackTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("周期内有 %d 条 AI 负反馈,优先查看负反馈原因和未解决问题,补齐 FAQ 后再复测。", report.NegativeFeedbackTotal)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("还有 %d 条 FAQ 草稿待确认,建议运营每天固定清一次,避免重复兜底。", report.PendingFAQDraftCount)) + } + if len(report.TopProducts) == 0 && report.LeadTotal > 0 { + suggestions = append(suggestions, "线索里缺少意向产品,建议检查留资抽取和产品推荐话术,否则后续很难判断哪个产品最有效。") + } + if len(report.TopChannels) > 0 && report.TopChannels[0].Name == "未标记渠道" && report.TopChannels[0].Count == report.LeadTotal { + suggestions = append(suggestions, "当前线索没有来源渠道,建议为网页、企微、广告落地页分别传入渠道标识。") + } + if len(suggestions) == 0 { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions = append(suggestions, "The selected period looks stable. Keep reviewing top products, channels, questions, and advisor follow-up quality every week.") + } else { + suggestions = append(suggestions, "当前周期经营趋势较稳定,建议每周继续复盘热门产品、渠道、问题和顾问跟进质量。") + } + } + return suggestions +} + +func buildBusinessTrendReportMarkdown(report response.DashboardBusinessTrendReportResponse) string { + var builder strings.Builder + title := "经营趋势复盘" + if report.Range == "30d" { + title = "月度经营趋势复盘" + } else if report.Range == "7d" { + title = "周度经营趋势复盘" + } + builder.WriteString(fmt.Sprintf("# %s(%s 至 %s)\n\n", title, report.StartDate, report.EndDate)) + builder.WriteString("## 核心指标\n") + builder.WriteString(fmt.Sprintf("- 咨询:%d\n", report.ConversationTotal)) + builder.WriteString(fmt.Sprintf("- 留资:%d,咨询到留资率:%.1f%%\n", report.LeadTotal, report.LeadConversionRate)) + builder.WriteString(fmt.Sprintf("- 高意向:%d,预约:%d,到店:%d,成交:%d\n", report.HighIntentTotal, report.AppointmentTotal, report.VisitedTotal, report.ConvertedTotal)) + builder.WriteString(fmt.Sprintf("- 转人工:%d,AI 负反馈:%d,待确认 FAQ 草稿:%d\n\n", report.HandoffTotal, report.NegativeFeedbackTotal, report.PendingFAQDraftCount)) + + builder.WriteString("## 产品与渠道\n") + builder.WriteString(markdownTopItems("热门产品", report.TopProducts, 5)) + builder.WriteString(markdownTopItems("来源渠道", report.TopChannels, 5)) + builder.WriteString("\n") + + builder.WriteString("## 问题与知识库\n") + builder.WriteString(markdownTopItems("高频问题", report.TopQuestions, 5)) + builder.WriteString(markdownTopItems("未解决问题", report.TopUnansweredQuestions, 5)) + builder.WriteString(markdownTopItems("负反馈原因", report.TopNegativeReasons, 5)) + builder.WriteString("\n") + + builder.WriteString("## 顾问跟进\n") + if len(report.AdvisorStats) == 0 { + builder.WriteString("- 暂无顾问跟进数据\n") + } else { + for _, advisor := range report.AdvisorStats { + builder.WriteString(fmt.Sprintf("- %s:线索 %d,跟进 %d,逾期 %d,成交 %d,无效 %d,转化率 %.1f%%,平均首跟进 %d 分钟\n", + advisor.OwnerUserName, + advisor.AssignedLeadCount, + advisor.FollowUpCount, + advisor.OverdueFollowUpCount, + advisor.ConvertedLeadCount, + advisor.InvalidLeadCount, + advisor.ConversionRate, + advisor.AverageFirstFollowUpMinutes, + )) + } + } + builder.WriteString("\n## 行动建议\n") + if len(report.Suggestions) == 0 { + builder.WriteString("- 暂无行动建议\n") + } else { + for _, suggestion := range report.Suggestions { + builder.WriteString(fmt.Sprintf("- %s\n", suggestion)) + } + } + return strings.TrimSpace(builder.String()) +} + +func markdownTopItems(title string, items []response.DashboardTopItemResponse, limit int) string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("### %s\n", title)) + if len(items) == 0 { + builder.WriteString("- 暂无数据\n") + return builder.String() + } + if limit <= 0 || limit > len(items) { + limit = len(items) + } + for _, item := range items[:limit] { + builder.WriteString(fmt.Sprintf("- %s:%d\n", item.Name, item.Count)) + } + return builder.String() +} + +func buildABTestSuggestions(locale string, report response.DashboardABTestReportResponse) []string { + suggestions := make([]string, 0, 4) + if report.LeadTotal == 0 { + suggestions = append(suggestions, "当前周期暂无线索样本。要做 A/B 对比,先让不同入口传入不同 sourceChannel,例如 opening_a、opening_b。") + return suggestions + } + if report.VariantTotal <= 1 { + suggestions = append(suggestions, "当前只有一个话术/渠道版本,建议至少准备两个入口标识,才能对比开场白、留资引导或预约话术。") + } + if len(report.Variants) > 0 { + best := report.Variants[0] + suggestions = append(suggestions, fmt.Sprintf("当前表现最好的是「%s」:留资 %d 条,预约率 %.1f%%,到店率 %.1f%%,成交率 %.1f%%。", best.VariantName, best.LeadCount, best.AppointmentRate, best.VisitRate, best.ConversionRate)) + } + if report.NegativeFeedbackTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("周期内 AI 负反馈 %d 条,负反馈率 %.1f%%。A/B 放量前先复核高风险回答,避免把有争议的话术继续扩大。", report.NegativeFeedbackTotal, report.NegativeFeedbackRate)) + } + if len(report.Variants) >= 2 { + best := report.Variants[0] + second := report.Variants[1] + if best.LeadCount >= 5 && second.LeadCount >= 5 && best.AppointmentRate-second.AppointmentRate >= 10 { + suggestions = append(suggestions, fmt.Sprintf("「%s」预约率比「%s」高 %.1f 个百分点,可优先复用它的预约引导。", best.VariantName, second.VariantName, best.AppointmentRate-second.AppointmentRate)) + } + } + if len(suggestions) == 0 { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions = append(suggestions, "Keep at least two tracked variants active and compare appointment, visit, and conversion rates weekly.") + } else { + suggestions = append(suggestions, "建议每周固定复盘至少两个话术版本的预约率、到店率和成交率,再决定保留或替换。") + } + } + return suggestions +} + +func (s *dashboardService) listReportHighIntentLeads(db *gorm.DB, dayStart, dayEnd time.Time, validStatuses []enums.SalesLeadStatus) []response.DashboardReportLeadResponse { + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND created_at < ? AND status IN ? AND intent_level = ?", dayStart, dayEnd, validStatuses, enums.SalesLeadIntentHigh). + Order("created_at desc"). + Limit(10). + Find(&leads) + + ret := make([]response.DashboardReportLeadResponse, 0, len(leads)) + for _, lead := range leads { + ret = append(ret, response.DashboardReportLeadResponse{ + ID: lead.ID, + CustomerName: lead.CustomerName, + Phone: lead.Phone, + WeChat: lead.WeChat, + City: lead.City, + InterestedProducts: lead.InterestedProducts, + DemandSummary: lead.DemandSummary, + BuyingStage: string(lead.BuyingStage), + AppointmentAt: formatDashboardTimePtr(lead.AppointmentAt), + AppointmentTimeText: lead.AppointmentTimeText, + AppointmentStore: lead.AppointmentStore, + AppointmentPeople: lead.AppointmentPeople, + Status: string(lead.Status), + OwnerUserID: lead.OwnerUserID, + OwnerUserName: dashboardLeadOwnerName(lead.OwnerUserID), + NextFollowUpAt: formatDashboardTimePtr(lead.NextFollowUpAt), + CreatedAt: lead.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + return ret +} + +func (s *dashboardService) countReportFollowUpRisks(db *gorm.DB, dayStart, dayEnd time.Time) (overdue int64, today int64, unscheduledHot int64) { + activeLeadStatuses := []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} + db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", dayStart). + Count(&overdue) + db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where("next_follow_up_at >= ? AND next_follow_up_at < ?", dayStart, dayEnd). + Count(&today) + db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("next_follow_up_at IS NULL"). + Where("(intent_level = ? OR buying_stage IN ?)", enums.SalesLeadIntentHigh, []enums.SalesLeadStage{enums.SalesLeadStageAppointment, enums.SalesLeadStageReadyToBuy}). + Count(&unscheduledHot) + return overdue, today, unscheduledHot +} + +func (s *dashboardService) countReportUnassignedPriorityLeads(db *gorm.DB, dayEnd time.Time) int64 { + activeLeadStatuses := []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} + var count int64 + db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where("owner_user_id = 0"). + Where( + "intent_level = ? OR buying_stage IN ? OR (next_follow_up_at IS NOT NULL AND next_follow_up_at < ?)", + enums.SalesLeadIntentHigh, + []enums.SalesLeadStage{enums.SalesLeadStageAppointment, enums.SalesLeadStageReadyToBuy, enums.SalesLeadStageAfterSales}, + dayEnd, + ). + Count(&count) + return count +} + +func (s *dashboardService) countReportAppointmentRisks(db *gorm.DB, dayStart, dayEnd time.Time) (overdue int64, today int64, unscheduled int64) { + activeLeadStatuses := []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} + base := func() *gorm.DB { + return db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where("(buying_stage = ? OR appointment_at IS NOT NULL OR appointment_time_text <> '' OR appointment_store <> '')", enums.SalesLeadStageAppointment) + } + base(). + Where("appointment_at IS NOT NULL AND appointment_at < ?", dayStart). + Count(&overdue) + base(). + Where("appointment_at >= ? AND appointment_at < ?", dayStart, dayEnd). + Count(&today) + base(). + Where("appointment_at IS NULL"). + Count(&unscheduled) + return overdue, today, unscheduled +} + +func (s *dashboardService) countReportAfterSalesTicketRisks(db *gorm.DB, dayStart, dayEnd time.Time) (pending int64, today int64, todayHandled int64) { + openBase := buildDashboardAfterSalesTicketQuery(db) + allBase := buildDashboardAfterSalesTicketKeywordQuery(db) + openBase().Count(&pending) + allBase(). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&today) + allBase(). + Where("status = ?", enums.TicketStatusDone). + Where("(handled_at >= ? AND handled_at < ?) OR (handled_at IS NULL AND updated_at >= ? AND updated_at < ?)", dayStart, dayEnd, dayStart, dayEnd). + Count(&todayHandled) + return pending, today, todayHandled +} + +func (s *dashboardService) countReportAIFeedbacks(db *gorm.DB, dayStart, dayEnd time.Time) (total int64, likes int64, negative int64) { + base := func() *gorm.DB { + return db.Model(&models.KnowledgeFeedback{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd) + } + base().Count(&total) + base(). + Where("feedback_type = ?", int(enums.KnowledgeFeedbackTypeLike)). + Count(&likes) + base(). + Where("feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Count(&negative) + return total, likes, negative +} + +func (s *dashboardService) listReportAfterSalesTickets(db *gorm.DB) []response.DashboardReportTicketResponse { + var tickets []models.Ticket + buildDashboardAfterSalesTicketQuery(db)(). + Order("updated_at DESC, id DESC"). + Limit(6). + Find(&tickets) + + progressMap := latestTicketProgressMap(db, tickets) + ret := make([]response.DashboardReportTicketResponse, 0, len(tickets)) + for _, ticket := range tickets { + progress := progressMap[ticket.ID] + ret = append(ret, response.DashboardReportTicketResponse{ + ID: ticket.ID, + TicketNo: ticket.TicketNo, + Title: ticket.Title, + Description: ticket.Description, + Status: string(ticket.Status), + CurrentAssigneeID: ticket.CurrentAssigneeID, + CurrentAssigneeName: dashboardTicketAssigneeName(ticket.CurrentAssigneeID), + ConversationID: ticket.ConversationID, + CustomerID: ticket.CustomerID, + LatestProgress: strings.TrimSpace(progress.Content), + LatestProgressAt: formatDashboardTime(progress.CreatedAt), + HandledAt: formatDashboardTimePtr(ticket.HandledAt), + CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04:05"), + }) + } + return ret +} + +func latestTicketProgressMap(db *gorm.DB, tickets []models.Ticket) map[int64]models.TicketProgress { + ret := map[int64]models.TicketProgress{} + ticketIDs := make([]int64, 0, len(tickets)) + for _, ticket := range tickets { + if ticket.ID > 0 { + ticketIDs = append(ticketIDs, ticket.ID) + } + } + if len(ticketIDs) == 0 { + return ret + } + var progressList []models.TicketProgress + db.Model(&models.TicketProgress{}). + Where("ticket_id IN ?", ticketIDs). + Order("created_at DESC, id DESC"). + Find(&progressList) + for _, progress := range progressList { + if _, exists := ret[progress.TicketID]; !exists { + ret[progress.TicketID] = progress + } + } + return ret +} + +func (s *dashboardService) listReportRecentNegativeAIFeedbacks(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardAIFeedbackResponse { + var feedbacks []models.KnowledgeFeedback + db.Model(&models.KnowledgeFeedback{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Order("created_at DESC, id DESC"). + Limit(5). + Find(&feedbacks) + + logIDs := make([]int64, 0, len(feedbacks)) + for _, item := range feedbacks { + if item.RetrieveLogID > 0 { + logIDs = append(logIDs, item.RetrieveLogID) + } + } + logMap := map[int64]models.KnowledgeRetrieveLog{} + if len(logIDs) > 0 { + var logs []models.KnowledgeRetrieveLog + db.Model(&models.KnowledgeRetrieveLog{}). + Where("id IN ?", logIDs). + Find(&logs) + for _, item := range logs { + logMap[item.ID] = item + } + } + + ret := make([]response.DashboardAIFeedbackResponse, 0, len(feedbacks)) + for _, item := range feedbacks { + feedbackType := enums.KnowledgeFeedbackType(item.FeedbackType) + logItem := logMap[item.RetrieveLogID] + ret = append(ret, response.DashboardAIFeedbackResponse{ + ID: item.ID, + RetrieveLogID: item.RetrieveLogID, + KnowledgeBaseID: logItem.KnowledgeBaseID, + FeedbackType: item.FeedbackType, + FeedbackTypeName: enums.GetKnowledgeFeedbackTypeLabel(feedbackType), + FeedbackReason: strings.TrimSpace(item.FeedbackReason), + Question: strings.TrimSpace(logItem.Question), + AnswerStatus: logItem.AnswerStatus, + AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(enums.KnowledgeAnswerStatus(logItem.AnswerStatus)), + ModelName: logItem.ModelName, + CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + return ret +} + +func (s *dashboardService) listRecentRiskAnswerSamples(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardAIRiskAnswerItem { + var logs []models.KnowledgeRetrieveLog + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("answer_status IN ?", []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + }). + Order("created_at DESC, id DESC"). + Limit(8). + Find(&logs) + + ret := make([]response.DashboardAIRiskAnswerItem, 0, len(logs)) + for _, item := range logs { + status := enums.KnowledgeAnswerStatus(item.AnswerStatus) + ret = append(ret, response.DashboardAIRiskAnswerItem{ + ID: item.ID, + KnowledgeBaseID: item.KnowledgeBaseID, + Question: strings.TrimSpace(item.Question), + AnswerStatus: item.AnswerStatus, + AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(status), + HitCount: item.HitCount, + TopScore: fmt.Sprintf("%.4f", item.TopScore), + ModelName: item.ModelName, + CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), + ActionHref: dashboardKnowledgeRetrieveLogHref(item.ID, item.KnowledgeBaseID), + }) + } + return ret +} + +func (s *dashboardService) listPendingQuestionGroups(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardPendingQuestionGroup { + type aggregate struct { + question string + count int64 + noAnswerCount int64 + fallbackCount int64 + blockedCount int64 + negativeFeedbackCount int64 + latestRetrieveLogID int64 + knowledgeBaseID int64 + latestAt time.Time + } + groups := map[string]*aggregate{} + upsert := func(log models.KnowledgeRetrieveLog, countAnswerStatus bool, negativeFeedback bool) { + question := normalizeDashboardQuestion(log.Question) + if question == "" { + question = normalizeDashboardQuestion(log.RewriteQuestion) + } + if question == "" { + question = fmt.Sprintf("检索日志 #%d", log.ID) + } + item := groups[question] + if item == nil { + item = &aggregate{question: question} + groups[question] = item + } + item.count++ + if countAnswerStatus { + switch enums.KnowledgeAnswerStatus(log.AnswerStatus) { + case enums.KnowledgeAnswerStatusNoAnswer: + item.noAnswerCount++ + case enums.KnowledgeAnswerStatusFallback: + item.fallbackCount++ + case enums.KnowledgeAnswerStatusBlocked: + item.blockedCount++ + } + } + if negativeFeedback { + item.negativeFeedbackCount++ + } + if log.CreatedAt.After(item.latestAt) || item.latestRetrieveLogID == 0 { + item.latestAt = log.CreatedAt + item.latestRetrieveLogID = log.ID + item.knowledgeBaseID = log.KnowledgeBaseID + } + } + + var riskLogs []models.KnowledgeRetrieveLog + db.Model(&models.KnowledgeRetrieveLog{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("answer_status IN ?", []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + }). + Find(&riskLogs) + for _, log := range riskLogs { + upsert(log, true, false) + } + + var negativeFeedbacks []models.KnowledgeFeedback + db.Model(&models.KnowledgeFeedback{}). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Find(&negativeFeedbacks) + logIDs := make([]int64, 0, len(negativeFeedbacks)) + for _, feedback := range negativeFeedbacks { + if feedback.RetrieveLogID > 0 { + logIDs = append(logIDs, feedback.RetrieveLogID) + } + } + if len(logIDs) > 0 { + var logs []models.KnowledgeRetrieveLog + db.Model(&models.KnowledgeRetrieveLog{}). + Where("id IN ?", logIDs). + Find(&logs) + logMap := make(map[int64]models.KnowledgeRetrieveLog, len(logs)) + for _, log := range logs { + logMap[log.ID] = log + } + for _, feedback := range negativeFeedbacks { + if log, ok := logMap[feedback.RetrieveLogID]; ok { + upsert(log, false, true) + } + } + } + + ret := make([]response.DashboardPendingQuestionGroup, 0, len(groups)) + for _, item := range groups { + ret = append(ret, response.DashboardPendingQuestionGroup{ + Question: item.question, + Count: item.count, + NoAnswerCount: item.noAnswerCount, + FallbackCount: item.fallbackCount, + BlockedCount: item.blockedCount, + NegativeFeedbackCount: item.negativeFeedbackCount, + LatestRetrieveLogID: item.latestRetrieveLogID, + KnowledgeBaseID: item.knowledgeBaseID, + LatestAt: formatDashboardTime(item.latestAt), + ActionHref: dashboardKnowledgeRetrieveLogHref(item.latestRetrieveLogID, item.knowledgeBaseID), + ActionLabel: "查看并生成 FAQ", + }) + } + sort.Slice(ret, func(i, j int) bool { + if ret[i].Count == ret[j].Count { + return ret[i].LatestAt > ret[j].LatestAt + } + return ret[i].Count > ret[j].Count + }) + if len(ret) > 6 { + ret = ret[:6] + } + return ret +} + +func (s *dashboardService) listReportPendingFAQDrafts(db *gorm.DB) (int64, []response.DashboardFAQDraftResponse) { + base := func() *gorm.DB { + return db.Model(&models.KnowledgeFAQ{}). + Where("status = ?", enums.StatusDisabled). + Where("remark LIKE ?", "%来源检索日志%") + } + var count int64 + base().Count(&count) + + var drafts []models.KnowledgeFAQ + base(). + Order("created_at DESC, id DESC"). + Limit(5). + Find(&drafts) + + ret := make([]response.DashboardFAQDraftResponse, 0, len(drafts)) + for _, item := range drafts { + ret = append(ret, response.DashboardFAQDraftResponse{ + ID: item.ID, + KnowledgeBaseID: item.KnowledgeBaseID, + Question: strings.TrimSpace(item.Question), + Answer: strings.TrimSpace(item.Answer), + Remark: strings.TrimSpace(item.Remark), + CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"), + }) + } + return count, ret +} + +func buildAIQualityTodos(noAnswerCount, fallbackCount, blockedCount, negativeFeedbackCount, pendingFAQDraftCount int64) []response.DashboardAIQualityTodoItem { + todos := make([]response.DashboardAIQualityTodoItem, 0, 5) + if noAnswerCount > 0 { + todos = append(todos, response.DashboardAIQualityTodoItem{ + Key: "no_answer", + Title: "补充无答案问题", + Description: "AI 未能回答的问题应优先转成 FAQ 或补充产品/活动知识。", + Count: noAnswerCount, + Level: "warning", + ActionHref: "/dashboard/knowledge?tab=retrieveLogs&answerStatus=2", + ActionLabel: "查看无答案", + }) + } + if fallbackCount > 0 { + todos = append(todos, response.DashboardAIQualityTodoItem{ + Key: "fallback", + Title: "复盘兜底回复", + Description: "兜底回复说明知识命中或答案边界不足,建议检查热门问题和引用来源。", + Count: fallbackCount, + Level: "warning", + ActionHref: "/dashboard/knowledge?tab=retrieveLogs&answerStatus=3", + ActionLabel: "查看兜底", + }) + } + if blockedCount > 0 { + todos = append(todos, response.DashboardAIQualityTodoItem{ + Key: "blocked", + Title: "检查风控拦截", + Description: "风控拦截可能来自敏感承诺或行业禁用口径,需要确认话术和转人工规则。", + Count: blockedCount, + Level: "error", + ActionHref: "/dashboard/knowledge?tab=retrieveLogs&answerStatus=4", + ActionLabel: "查看拦截", + }) + } + if negativeFeedbackCount > 0 { + todos = append(todos, response.DashboardAIQualityTodoItem{ + Key: "negative_feedback", + Title: "处理 AI 负反馈", + Description: "点踩、无帮助和引用错误应尽快复盘,必要时生成 FAQ 草稿。", + Count: negativeFeedbackCount, + Level: "warning", + ActionHref: "/dashboard/knowledge?tab=retrieveLogs&feedback=negative", + ActionLabel: "查看负反馈", + }) + } + if pendingFAQDraftCount > 0 { + todos = append(todos, response.DashboardAIQualityTodoItem{ + Key: "pending_faq_drafts", + Title: "确认 FAQ 草稿", + Description: "由检索日志生成的 FAQ 草稿需要人工确认后才能进入正式知识库。", + Count: pendingFAQDraftCount, + Level: "info", + ActionHref: "/dashboard/knowledge?tab=documents&status=1", + ActionLabel: "查看草稿", + }) + } + return todos +} + +func buildAIQualityKnowledgeSuggestions(locale string, report response.DashboardAIQualityReportResponse) []string { + suggestions := make([]string, 0, 5) + if report.RiskAnswerCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("近 %s 有 %d 次无答案/兜底/风控回复,先处理未解决问题 Top3。", report.Range, report.RiskAnswerCount)) + } + if len(report.UnansweredQuestions) > 0 { + top := report.UnansweredQuestions[0] + suggestions = append(suggestions, fmt.Sprintf("优先补充「%s」相关知识,近周期出现 %d 次未解决。", top.Name, top.Count)) + } + if report.NegativeFeedbackCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("近周期 AI 负反馈 %d 条,负反馈率 %.1f%%,建议逐条查看引用和回答边界。", report.NegativeFeedbackCount, report.NegativeFeedbackRate)) + } + if len(report.TopNegativeReasons) > 0 { + top := report.TopNegativeReasons[0] + suggestions = append(suggestions, fmt.Sprintf("负反馈最常见原因是「%s」,出现 %d 次,可针对该类问题补 FAQ 或调整话术。", top.Name, top.Count)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("还有 %d 条 FAQ 草稿待确认,确认后记得重建索引。", report.PendingFAQDraftCount)) + } + if len(suggestions) == 0 { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions = append(suggestions, "AI answer quality looks stable for the selected period. Keep reviewing new negative feedback weekly.") + } else { + suggestions = append(suggestions, "当前周期 AI 回答质量稳定,建议每周继续复盘新增负反馈和热门问题。") + } + } + return suggestions +} + +func dashboardKnowledgeRetrieveLogHref(retrieveLogID int64, knowledgeBaseID int64) string { + params := fmt.Sprintf("tab=retrieveLogs&retrieveLogId=%d", retrieveLogID) + if knowledgeBaseID > 0 { + params += fmt.Sprintf("&knowledgeBaseId=%d", knowledgeBaseID) + } + return "/dashboard/knowledge?" + params +} + +func buildTopAIFeedbackReasons(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardTopItemResponse { + var feedbacks []models.KnowledgeFeedback + db.Model(&models.KnowledgeFeedback{}). + Select("feedback_type, feedback_reason"). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Find(&feedbacks) + + counts := make(map[string]int64) + for _, item := range feedbacks { + reason := normalizeDashboardQuestion(item.FeedbackReason) + if reason == "" { + reason = enums.GetKnowledgeFeedbackTypeLabel(enums.KnowledgeFeedbackType(item.FeedbackType)) + } + if reason == "" { + reason = "其他" + } + counts[reason]++ + } + items := make([]response.DashboardTopItemResponse, 0, len(counts)) + for reason, count := range counts { + items = append(items, response.DashboardTopItemResponse{Name: reason, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count == items[j].Count { + return items[i].Name < items[j].Name + } + return items[i].Count > items[j].Count + }) + if len(items) > 5 { + items = items[:5] + } + return items +} - agentProfiles := repositories.DashboardRepository.ListEnabledAgentProfiles(db) - agentTeams := repositories.DashboardRepository.ListEnabledAgentTeams(db) - activeSchedules := repositories.DashboardRepository.ListActiveTeamSchedules(db, now, now) - activeConversations := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("status IN ?", []enums.IMConversationStatus{ - enums.IMConversationStatusAIServing, - enums.IMConversationStatusPending, - enums.IMConversationStatusActive, +func buildDashboardAfterSalesTicketKeywordQuery(db *gorm.DB) func() *gorm.DB { + keywords := []string{"售后", "投诉", "退款", "退货", "退换", "异响", "差评", "不满意", "质保", "安装"} + return func() *gorm.DB { + tx := db.Model(&models.Ticket{}). + Where("source = ?", enums.TicketSourceConversation) + keywordTx := db.Where("title LIKE ? OR description LIKE ?", "%"+keywords[0]+"%", "%"+keywords[0]+"%") + for _, keyword := range keywords[1:] { + keywordTx = keywordTx.Or("title LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + return tx.Where(keywordTx) + } +} + +func buildDashboardAfterSalesTicketQuery(db *gorm.DB) func() *gorm.DB { + base := buildDashboardAfterSalesTicketKeywordQuery(db) + return func() *gorm.DB { + return base().Where("status <> ?", enums.TicketStatusDone) + } +} + +func (s *dashboardService) listReportPriorityFollowUps(db *gorm.DB, dayStart, dayEnd time.Time) []response.DashboardReportLeadResponse { + activeLeadStatuses := []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Where("status IN ?", activeLeadStatuses). + Where(`(next_follow_up_at IS NOT NULL AND next_follow_up_at < ?) OR (created_at >= ? AND created_at < ? AND next_follow_up_at IS NULL AND (intent_level = ? OR buying_stage IN ?))`, + dayEnd, + dayStart, + dayEnd, + enums.SalesLeadIntentHigh, + []enums.SalesLeadStage{enums.SalesLeadStageAppointment, enums.SalesLeadStageReadyToBuy}, + ). + Order("CASE WHEN next_follow_up_at IS NULL THEN 1 ELSE 0 END ASC, next_follow_up_at ASC, created_at DESC"). + Limit(8). + Find(&leads) + + ret := make([]response.DashboardReportLeadResponse, 0, len(leads)) + for _, lead := range leads { + ret = append(ret, response.DashboardReportLeadResponse{ + ID: lead.ID, + CustomerName: lead.CustomerName, + Phone: lead.Phone, + WeChat: lead.WeChat, + City: lead.City, + InterestedProducts: lead.InterestedProducts, + DemandSummary: lead.DemandSummary, + BuyingStage: string(lead.BuyingStage), + AppointmentAt: formatDashboardTimePtr(lead.AppointmentAt), + AppointmentTimeText: lead.AppointmentTimeText, + AppointmentStore: lead.AppointmentStore, + AppointmentPeople: lead.AppointmentPeople, + Status: string(lead.Status), + OwnerUserID: lead.OwnerUserID, + OwnerUserName: dashboardLeadOwnerName(lead.OwnerUserID), + NextFollowUpAt: formatDashboardTimePtr(lead.NextFollowUpAt), + FollowUpState: dashboardFollowUpState(lead.NextFollowUpAt, dayStart, dayEnd), + CreatedAt: lead.CreatedAt.Format("2006-01-02 15:04:05"), }) - }) + } + return ret +} - onlineAgents, busyAgents, offlineAgents, teamLoads := s.buildAgentStats(now, agentTeams, agentProfiles, activeSchedules, activeConversations) +func dashboardTicketAssigneeName(userID int64) string { + if userID <= 0 { + return "" + } + if user := UserService.Get(userID); user != nil { + return user.Username + } + return "" +} - enabledAIAgentCount := repositories.DashboardRepository.CountAIAgents(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("status = ?", enums.StatusOk) - }) - enabledChannelCount := repositories.DashboardRepository.CountChannels(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("status = ?", enums.StatusOk) - }) - knowledgeRetrieveCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("created_at >= ?", todayStart) - }) - knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4}) - }) - skillRunFailCount := repositories.DashboardRepository.CountSkillRunLogs(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("created_at >= ? AND error_message <> ''", todayStart) - }) - aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("handoff_at >= ?", todayStart) - }) +func dashboardLeadOwnerName(ownerUserID int64) string { + if ownerUserID <= 0 { + return "" + } + if owner := UserService.Get(ownerUserID); owner != nil { + return owner.Username + } + return "" +} - enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("status = ?", enums.StatusOk) - }) - alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules, locale) +func dashboardFollowUpState(value *time.Time, dayStart, dayEnd time.Time) string { + if value == nil { + return "unscheduled" + } + if value.Before(dayStart) { + return "overdue" + } + if value.Before(dayEnd) { + return "today" + } + return "scheduled" +} - return response.DashboardOverviewResponse{ - Range: normalizedRange, - GeneratedAt: now.Format("2006-01-02 15:04:05"), - Summary: response.DashboardSummaryResponse{ - TodayNewConversations: conversationTodayCount, - ProcessingConversations: processingConversationCount, - PendingDispatchConversations: pendingConversationCount, - OnlineAgents: onlineAgents, - AIServiceRate: calcAIServiceRate(activeConversations), - }, - ConversationStats: response.DashboardSectionStatsResponse{ - StatusDistribution: buildConversationStatusDistribution(db, locale), - Trend: buildConversationTrend(db, trendStart), - }, - AgentStats: response.DashboardAgentStatsResponse{ - OnlineAgents: onlineAgents, - BusyAgents: busyAgents, - OfflineAgents: offlineAgents, - TeamLoads: teamLoads, - }, - AIStats: response.DashboardAIStatsResponse{ - EnabledAIAgents: enabledAIAgentCount, - EnabledChannels: enabledChannelCount, - TodayKnowledgeRetrieves: knowledgeRetrieveCount, - TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount, - TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount), - TodaySkillRunFailCount: skillRunFailCount, - TodayAIHandoffCount: aiHandoffCount, - }, - Alerts: alerts, - QuickLinks: buildDashboardQuickLinks(locale), +func (s *dashboardService) buildDigitalStoreStats(db *gorm.DB, todayStart, rangeStart, now time.Time, conversationTodayCount int64, aiHandoffCount int64, locale string) response.DashboardDigitalStoreResponse { + validLeadStatuses := []enums.SalesLeadStatus{ + enums.SalesLeadStatusNew, + enums.SalesLeadStatusFollowing, + enums.SalesLeadStatusVisited, + enums.SalesLeadStatusConverted, + } + + var todayLeads int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND status IN ?", todayStart, validLeadStatuses). + Count(&todayLeads) + + var todayHighIntentLeads int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND status IN ? AND intent_level = ?", todayStart, validLeadStatuses, enums.SalesLeadIntentHigh). + Count(&todayHighIntentLeads) + + var todayAppointmentLeads int64 + db.Model(&models.SalesLead{}). + Where("created_at >= ? AND status IN ? AND buying_stage = ?", todayStart, validLeadStatuses, enums.SalesLeadStageAppointment). + Count(&todayAppointmentLeads) + + var todayConvertedLeads int64 + db.Model(&models.SalesLead{}). + Where("updated_at >= ? AND status = ?", todayStart, enums.SalesLeadStatusConverted). + Count(&todayConvertedLeads) + + var pendingFollowUpLeads int64 + db.Model(&models.SalesLead{}). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Count(&pendingFollowUpLeads) + + var activeProducts int64 + db.Model(&models.Product{}). + Where("status = ?", enums.StatusOk). + Count(&activeProducts) + + var activePromotions int64 + db.Model(&models.Promotion{}). + Where("status = ? AND (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at >= ?)", enums.StatusOk, now, now). + Count(&activePromotions) + + return response.DashboardDigitalStoreResponse{ + TodayConsultations: conversationTodayCount, + TodayLeads: todayLeads, + LeadConversionRate: calcRate(todayLeads, conversationTodayCount), + TodayHighIntentLeads: todayHighIntentLeads, + TodayAppointmentLeads: todayAppointmentLeads, + TodayConvertedLeads: todayConvertedLeads, + PendingFollowUpLeads: pendingFollowUpLeads, + ActiveProducts: activeProducts, + ActivePromotions: activePromotions, + TodayHandoffs: aiHandoffCount, + TopLeadProducts: buildTopLeadProducts(db, rangeStart, validLeadStatuses), + Summary: buildDigitalStoreSummary(locale, todayLeads, conversationTodayCount, todayHighIntentLeads, todayAppointmentLeads, activeProducts, activePromotions), } } @@ -411,6 +2192,393 @@ func calcAIServiceRate(conversations []models.Conversation) float64 { return calcRate(aiCount, total) } +func buildTopLeadProducts(db *gorm.DB, rangeStart time.Time, validStatuses []enums.SalesLeadStatus) []response.DashboardTopItemResponse { + var leads []models.SalesLead + db.Model(&models.SalesLead{}). + Select("interested_products"). + Where("created_at >= ? AND status IN ? AND interested_products <> ''", rangeStart, validStatuses). + Find(&leads) + + productCounts := make(map[string]int64) + for _, lead := range leads { + for _, name := range splitLeadProductNames(lead.InterestedProducts) { + productCounts[name]++ + } + } + + items := make([]response.DashboardTopItemResponse, 0, len(productCounts)) + for name, count := range productCounts { + items = append(items, response.DashboardTopItemResponse{Name: name, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count == items[j].Count { + return items[i].Name < items[j].Name + } + return items[i].Count > items[j].Count + }) + if len(items) > 5 { + items = items[:5] + } + return items +} + +func splitLeadProductNames(value string) []string { + replacer := strings.NewReplacer( + ",", ",", + "、", ",", + ";", ",", + ";", ",", + "|", ",", + "/", ",", + "\n", ",", + ) + parts := strings.Split(replacer.Replace(value), ",") + names := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" || seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + return names +} + +func buildTopKnowledgeQuestions(db *gorm.DB, dayStart, dayEnd time.Time, answerStatuses []int) []response.DashboardTopItemResponse { + var logs []models.KnowledgeRetrieveLog + tx := db.Model(&models.KnowledgeRetrieveLog{}). + Select("question, rewrite_question"). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Where("(question <> '' OR rewrite_question <> '')") + if len(answerStatuses) > 0 { + tx = tx.Where("answer_status IN ?", answerStatuses) + } + tx.Find(&logs) + + counts := make(map[string]int64) + for _, item := range logs { + question := normalizeDashboardQuestion(item.Question) + if question == "" { + question = normalizeDashboardQuestion(item.RewriteQuestion) + } + if question == "" { + continue + } + counts[question]++ + } + items := make([]response.DashboardTopItemResponse, 0, len(counts)) + for question, count := range counts { + items = append(items, response.DashboardTopItemResponse{Name: question, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count == items[j].Count { + return items[i].Name < items[j].Name + } + return items[i].Count > items[j].Count + }) + if len(items) > 5 { + items = items[:5] + } + return items +} + +func normalizeDashboardQuestion(value string) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + value = strings.Trim(value, " \t\r\n。.!!??") + if value == "" { + return "" + } + runes := []rune(value) + if len(runes) > 80 { + value = string(runes[:80]) + "..." + } + return value +} + +func resolveReportDay(value string, now time.Time) (string, time.Time, time.Time) { + location := now.Location() + if parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), location); err == nil { + start := startOfDay(parsed) + return start.Format("2006-01-02"), start, start.AddDate(0, 0, 1) + } + start := startOfDay(now) + return start.Format("2006-01-02"), start, start.AddDate(0, 0, 1) +} + +func formatDashboardTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return formatDashboardTime(*value) +} + +func formatDashboardTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format("2006-01-02 15:04:05") +} + +func buildDailyBusinessReportSummary(locale string, report response.DashboardDailyBusinessReportResponse) string { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + return fmt.Sprintf("%s: the digital store manager handled %d inquiries, sent %d AI replies, captured %d leads, identified %d high-intent customers, and closed %d leads. Lead conversion was %.1f%%.", + report.ReportDate, report.ConversationCount, report.AIReplyCount, report.LeadCount, report.HighIntentCount, report.ConvertedCount, report.LeadConversionRate) + } + return fmt.Sprintf("%s 数字店长复盘:今日承接 %d 个咨询,AI 回复 %d 次,沉淀 %d 条线索,识别高意向客户 %d 条,成交线索 %d 条,留资转化率 %.1f%%。", + report.ReportDate, report.ConversationCount, report.AIReplyCount, report.LeadCount, report.HighIntentCount, report.ConvertedCount, report.LeadConversionRate) +} + +func buildDailyBusinessReportHighlights(locale string, report response.DashboardDailyBusinessReportResponse) []string { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + return []string{ + fmt.Sprintf("%d active products and %d live promotions are available for recommendations.", report.ActiveProductCount, report.ActivePromotionCount), + fmt.Sprintf("%d conversations were handed off to human agents.", report.HandoffCount), + fmt.Sprintf("%d customers reached appointment or ready-to-buy stages.", report.AppointmentCount), + fmt.Sprintf("%d priority leads are still unassigned.", report.UnassignedPriorityLeadCount), + fmt.Sprintf("%d leads were marked converted today.", report.ConvertedCount), + fmt.Sprintf("%d after-sales or complaint tickets are still open, and %d were handled today.", report.PendingAfterSalesTicketCount, report.TodayHandledAfterSalesTicketCount), + fmt.Sprintf("%d AI answer feedbacks were recorded, with %.1f%% negative feedback.", report.AIFeedbackCount, report.AIFeedbackNegativeRate), + } + } + return []string{ + fmt.Sprintf("当前可推荐 %d 个在售产品、%d 个有效活动。", report.ActiveProductCount, report.ActivePromotionCount), + fmt.Sprintf("今日转人工 %d 次,可结合原因判断是否需要补知识库或优化话术。", report.HandoffCount), + fmt.Sprintf("已有 %d 位客户进入预约/临门购买阶段,适合优先跟进。", report.AppointmentCount), + fmt.Sprintf("当前还有 %d 条重点线索未分配负责人。", report.UnassignedPriorityLeadCount), + fmt.Sprintf("今日已标记成交 %d 条线索,可用于复盘顾问转化结果。", report.ConvertedCount), + fmt.Sprintf("当前还有 %d 个售后/投诉工单未处理,今日已处理 %d 个。", report.PendingAfterSalesTicketCount, report.TodayHandledAfterSalesTicketCount), + fmt.Sprintf("今日收到 %d 条 AI 回答反馈,负反馈率 %.1f%%。", report.AIFeedbackCount, report.AIFeedbackNegativeRate), + } +} + +func buildDailyBusinessReportFollowUps(locale string, report response.DashboardDailyBusinessReportResponse) []string { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions := []string{ + fmt.Sprintf("Follow up with %d high-intent leads first and confirm appointment times.", report.HighIntentCount), + } + if report.OverdueFollowUpCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d follow-ups are overdue; assign an owner and contact them before new leads.", report.OverdueFollowUpCount)) + } + if report.TodayFollowUpCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d leads are due today; confirm next actions in the sales lead list.", report.TodayFollowUpCount)) + } + if report.UnscheduledHotLeads > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d high-intent or appointment leads do not have a next follow-up time; schedule them now.", report.UnscheduledHotLeads)) + } + if report.UnassignedPriorityLeadCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d priority leads are unassigned; claim or assign them before they cool down.", report.UnassignedPriorityLeadCount)) + } + if report.OverdueAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d appointments are overdue; confirm whether customers visited or need a new time.", report.OverdueAppointmentCount)) + } + if report.TodayAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d appointments are scheduled today; remind store advisors before customers arrive.", report.TodayAppointmentCount)) + } + if report.UnscheduledAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d appointment-intent leads still need a confirmed time or store.", report.UnscheduledAppointmentCount)) + } + if report.PendingAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d after-sales or complaint tickets are still open; assign an owner and update progress before closing the day.", report.PendingAfterSalesTicketCount)) + } + if report.TodayAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d new after-sales or complaint tickets were created today; check whether they share a product or policy issue.", report.TodayAfterSalesTicketCount)) + } + if report.TodayHandledAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d after-sales or complaint tickets were handled today; verify progress notes before sending the daily recap.", report.TodayHandledAfterSalesTicketCount)) + } + if report.AIFeedbackNegativeCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d AI answers received negative feedback; review feedback reasons and update FAQ or product guidance.", report.AIFeedbackNegativeCount)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("%d FAQ drafts are waiting for review; edit and enable them after confirming the answer.", report.PendingFAQDraftCount)) + } + if report.UnresolvedCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("Review %d unresolved active conversations before closing the day.", report.UnresolvedCount)) + } + if report.LeadCount == 0 && report.ConversationCount > 0 { + suggestions = append(suggestions, "Review lead-capture wording because inquiries did not become leads today.") + } + if report.ConvertedCount == 0 && report.HighIntentCount > 0 { + suggestions = append(suggestions, "No converted leads were recorded today; review high-intent follow-ups and mark outcomes promptly.") + } + return suggestions + } + + suggestions := []string{ + fmt.Sprintf("优先跟进 %d 条高意向线索,确认试躺时间、预算和具体门店。", report.HighIntentCount), + } + if report.OverdueFollowUpCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条线索已逾期未跟进,请先分配负责人并联系客户。", report.OverdueFollowUpCount)) + } + if report.TodayFollowUpCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("今天还有 %d 条线索需要跟进,建议在销售线索页逐条记录下一步动作。", report.TodayFollowUpCount)) + } + if report.UnscheduledHotLeads > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条今日高意向/预约线索还没设置下次跟进时间,请立刻补齐。", report.UnscheduledHotLeads)) + } + if report.UnassignedPriorityLeadCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条重点线索未分配负责人,请在线索页筛选“未分配”后领取或指派顾问。", report.UnassignedPriorityLeadCount)) + } + if report.OverdueAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条预约已逾期未到店,请确认客户是否到访或重新约时间。", report.OverdueAppointmentCount)) + } + if report.TodayAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("今天有 %d 条预约到店,请提前提醒门店顾问准备接待。", report.TodayAppointmentCount)) + } + if report.UnscheduledAppointmentCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 条预约意向还没确认具体时间或门店,请优先补齐。", report.UnscheduledAppointmentCount)) + } + if report.PendingAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("有 %d 个售后/投诉工单未处理,请分配负责人并在当天更新处理进展。", report.PendingAfterSalesTicketCount)) + } + if report.TodayAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("今日新增 %d 个售后/投诉工单,建议复盘是否集中在某个产品、安装或质保口径。", report.TodayAfterSalesTicketCount)) + } + if report.TodayHandledAfterSalesTicketCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("今日已处理 %d 个售后/投诉工单,请确认处理进展已写清楚,方便老板判断是否真正闭环。", report.TodayHandledAfterSalesTicketCount)) + } + if report.AIFeedbackNegativeCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("今日有 %d 条 AI 回答负反馈,请结合反馈原因补充 FAQ、产品话术或禁用承诺。", report.AIFeedbackNegativeCount)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("还有 %d 条 FAQ 草稿待确认,请编辑标准答案后启用,让修正口径进入知识库。", report.PendingFAQDraftCount)) + } + if report.UnresolvedCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("复查 %d 个仍未解决的活跃会话,避免客户流失。", report.UnresolvedCount)) + } + if report.LeadCount == 0 && report.ConversationCount > 0 { + suggestions = append(suggestions, "今日有咨询但没有留资,建议优化 AI 的留资邀约话术。") + } + if report.ConvertedCount == 0 && report.HighIntentCount > 0 { + suggestions = append(suggestions, "今日还没有记录成交线索,请复查高意向客户跟进结果并及时标记成交或无效。") + } + return suggestions +} + +func buildDailyBusinessReportKnowledgeSuggestions(locale string, report response.DashboardDailyBusinessReportResponse) []string { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + suggestions := []string{"Review unanswered handoff reasons and add missing FAQ entries."} + if len(report.UnansweredQuestions) > 0 { + suggestions = append(suggestions, fmt.Sprintf("Add or improve FAQ coverage for: %s.", report.UnansweredQuestions[0].Name)) + } + if len(report.TopLeadProducts) > 0 { + suggestions = append(suggestions, fmt.Sprintf("Enrich product guidance for %s because it appeared in lead interest.", report.TopLeadProducts[0].Name)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("Review %d pending FAQ drafts generated from AI feedback before they go stale.", report.PendingFAQDraftCount)) + } + return suggestions + } + + suggestions := []string{"复盘转人工原因,把重复问题补成 FAQ 或产品话术。"} + if len(report.UnansweredQuestions) > 0 { + suggestions = append(suggestions, fmt.Sprintf("优先补充“%s”的标准答案,今天该问题出现未解决/兜底。", report.UnansweredQuestions[0].Name)) + } + if len(report.TopLeadProducts) > 0 { + suggestions = append(suggestions, fmt.Sprintf("客户今天更关注“%s”,建议补充该产品的适用人群、价格区间和到店体验话术。", report.TopLeadProducts[0].Name)) + } + if len(report.TopAIFeedbackReasons) > 0 { + suggestions = append(suggestions, fmt.Sprintf("优先复盘 AI 负反馈原因“%s”,把对应口径补进知识库或店长禁用承诺。", report.TopAIFeedbackReasons[0].Name)) + } + if report.PendingFAQDraftCount > 0 { + suggestions = append(suggestions, fmt.Sprintf("当前有 %d 条 FAQ 草稿待确认,建议当天完成编辑并启用,避免同类问题继续答错。", report.PendingFAQDraftCount)) + } + return suggestions +} + +func buildDailyBusinessReportWebhookText(report response.DashboardDailyBusinessReportResponse) string { + lines := []string{ + report.Summary, + "", + "核心指标", + fmt.Sprintf("- 咨询:%d,AI回复:%d,转人工:%d", report.ConversationCount, report.AIReplyCount, report.HandoffCount), + fmt.Sprintf("- 留资:%d,高意向:%d,预约:%d,成交:%d,转化率:%.1f%%", report.LeadCount, report.HighIntentCount, report.AppointmentCount, report.ConvertedCount, report.LeadConversionRate), + fmt.Sprintf("- 逾期跟进:%d,今日跟进:%d,未排计划高意向:%d,未分配重点线索:%d", report.OverdueFollowUpCount, report.TodayFollowUpCount, report.UnscheduledHotLeads, report.UnassignedPriorityLeadCount), + fmt.Sprintf("- 逾期预约:%d,今日预约:%d,未定预约:%d", report.OverdueAppointmentCount, report.TodayAppointmentCount, report.UnscheduledAppointmentCount), + fmt.Sprintf("- 售后/投诉未处理:%d,今日新增:%d,今日已处理:%d", report.PendingAfterSalesTicketCount, report.TodayAfterSalesTicketCount, report.TodayHandledAfterSalesTicketCount), + fmt.Sprintf("- AI负反馈:%d,负反馈率:%.1f%%,待确认FAQ草稿:%d", report.AIFeedbackNegativeCount, report.AIFeedbackNegativeRate, report.PendingFAQDraftCount), + } + appendTopItems := func(title string, items []response.DashboardTopItemResponse) { + lines = append(lines, "", title) + if len(items) == 0 { + lines = append(lines, "- 暂无") + return + } + for _, item := range items { + lines = append(lines, fmt.Sprintf("- %s(%d次)", item.Name, item.Count)) + } + } + appendTopItems("热门咨询问题", report.TopQuestions) + appendTopItems("未解决问题", report.UnansweredQuestions) + if len(report.PriorityFollowUps) > 0 { + lines = append(lines, "", "优先跟进") + for _, lead := range report.PriorityFollowUps { + contact := strings.TrimSpace(lead.Phone) + if contact == "" { + contact = strings.TrimSpace(lead.WeChat) + } + if contact == "" { + contact = "暂无联系方式" + } + lines = append(lines, fmt.Sprintf("- %s / %s / %s / %s", dashboardReportValueOrDash(lead.CustomerName), contact, dashboardReportValueOrDash(lead.OwnerUserName), dashboardReportValueOrDash(lead.NextFollowUpAt))) + } + } + if len(report.AfterSalesTickets) > 0 { + lines = append(lines, "", "售后/投诉工单进展") + for _, ticket := range report.AfterSalesTickets { + owner := dashboardReportValueOrDash(ticket.CurrentAssigneeName) + progress := dashboardReportValueOrDash(ticket.LatestProgress) + if ticket.LatestProgressAt != "" { + progress = progress + "(" + ticket.LatestProgressAt + ")" + } + lines = append(lines, fmt.Sprintf("- %s / %s / %s / %s / 最近进展:%s", + dashboardReportValueOrDash(ticket.TicketNo), + dashboardReportValueOrDash(enums.GetTicketStatusLabel(enums.TicketStatus(ticket.Status))), + owner, + dashboardReportValueOrDash(ticket.Title), + progress, + )) + } + } + if len(report.FollowUpSuggestions) > 0 { + lines = append(lines, "", "跟进建议") + for _, item := range report.FollowUpSuggestions { + lines = append(lines, "- "+item) + } + } + if len(report.KnowledgeSuggestions) > 0 { + lines = append(lines, "", "知识库建议") + for _, item := range report.KnowledgeSuggestions { + lines = append(lines, "- "+item) + } + } + lines = append(lines, "", "后台入口:/dashboard") + return strings.Join(lines, "\n") +} + +func dashboardReportValueOrDash(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "-" + } + return value +} + +func buildDigitalStoreSummary(locale string, todayLeads, todayConsultations, todayHighIntentLeads, todayAppointmentLeads, activeProducts, activePromotions int64) string { + if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS { + if todayConsultations <= 0 { + return fmt.Sprintf("The store manager is ready with %d active products and %d live promotions.", activeProducts, activePromotions) + } + return fmt.Sprintf("Today the store manager handled %d inquiries, captured %d leads, found %d high-intent customers, and moved %d toward appointments.", todayConsultations, todayLeads, todayHighIntentLeads, todayAppointmentLeads) + } + if todayConsultations <= 0 { + return fmt.Sprintf("数字店长已准备好 %d 个在售产品和 %d 个有效活动,可开始承接客户咨询。", activeProducts, activePromotions) + } + return fmt.Sprintf("今日数字店长承接 %d 个咨询,沉淀 %d 条线索,其中高意向 %d 条、预约阶段 %d 条。", todayConsultations, todayLeads, todayHighIntentLeads, todayAppointmentLeads) +} + func labelOrDefault(value, fallback string) string { if strings.TrimSpace(value) != "" { return value diff --git a/internal/services/dashboard_service_test.go b/internal/services/dashboard_service_test.go index 73810a97..1b366fcc 100644 --- a/internal/services/dashboard_service_test.go +++ b/internal/services/dashboard_service_test.go @@ -1,9 +1,23 @@ package services import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" - "testing" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) func TestDashboardTextUsesEnglishLocale(t *testing.T) { @@ -27,3 +41,846 @@ func TestConversationStatusLabelUsesEnglishLocale(t *testing.T) { t.Fatalf("conversationStatusLabel() = %q", got) } } + +func TestBuildTopKnowledgeQuestions(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeRetrieveLog{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + now := time.Date(2026, 7, 5, 10, 0, 0, 0, time.Local) + logs := []models.KnowledgeRetrieveLog{ + {Question: "老人腰不好,床垫是不是越硬越好?", AnswerStatus: 1, CreatedAt: now}, + {Question: " 老人腰不好,床垫是不是越硬越好? ", AnswerStatus: 2, CreatedAt: now.Add(time.Minute)}, + {Question: "周末有什么活动", AnswerStatus: 3, CreatedAt: now.Add(2 * time.Minute)}, + {Question: "昨天的问题", AnswerStatus: 2, CreatedAt: now.AddDate(0, 0, -1)}, + } + for i := range logs { + if err := db.Create(&logs[i]).Error; err != nil { + t.Fatalf("create log: %v", err) + } + } + + all := buildTopKnowledgeQuestions(db, startOfDay(now), startOfDay(now).AddDate(0, 0, 1), nil) + if len(all) == 0 || all[0].Name != "老人腰不好,床垫是不是越硬越好" || all[0].Count != 2 { + t.Fatalf("unexpected top questions: %#v", all) + } + unanswered := buildTopKnowledgeQuestions(db, startOfDay(now), startOfDay(now).AddDate(0, 0, 1), []int{2, 3, 4}) + if len(unanswered) != 2 { + t.Fatalf("unexpected unanswered questions: %#v", unanswered) + } +} + +func TestDailyBusinessReportIncludesPriorityFollowUps(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Conversation{}, + &models.Message{}, + &models.SalesLead{}, + &models.Product{}, + &models.Promotion{}, + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeFeedback{}, + &models.KnowledgeFAQ{}, + &models.User{}, + &models.Ticket{}, + &models.TicketProgress{}, + &models.SystemConfig{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + reportDay := time.Date(2026, 7, 6, 9, 0, 0, 0, time.Local) + dayStart := startOfDay(reportDay) + overdue := dayStart.Add(-2 * time.Hour) + today := dayStart.Add(10 * time.Hour) + future := dayStart.AddDate(0, 0, 2) + appointmentOverdue := dayStart.Add(-4 * time.Hour) + appointmentToday := dayStart.Add(15 * time.Hour) + ticketHandledAt := dayStart.Add(5 * time.Hour) + owner := models.User{Username: "顾问A", Status: enums.StatusOk} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner: %v", err) + } + leads := []models.SalesLead{ + {CustomerName: "逾期客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, OwnerUserID: owner.ID, NextFollowUpAt: &overdue, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(-24 * time.Hour)}}, + {CustomerName: "今日客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, NextFollowUpAt: &today, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour)}}, + {CustomerName: "未排计划高意向", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(2 * time.Hour)}}, + {CustomerName: "未来客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &future, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(3 * time.Hour)}}, + {CustomerName: "逾期预约客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentOverdue, AppointmentStore: "徐汇店", AuditFields: models.AuditFields{CreatedAt: dayStart.Add(-24 * time.Hour)}}, + {CustomerName: "今日预约客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentToday, AppointmentStore: "静安店", AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour)}}, + {CustomerName: "未定预约客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentTimeText: "周末方便", AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour)}}, + {CustomerName: "已转化预约客户", Status: enums.SalesLeadStatusConverted, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentToday, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour), UpdatedAt: dayStart.Add(16 * time.Hour)}}, + } + for i := range leads { + if err := db.Create(&leads[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + tickets := []models.Ticket{ + { + TicketNo: "TK202607060001", + Title: "售后/投诉风险待处理", + Description: "客户反馈床垫异响,要求售后处理。", + Source: enums.TicketSourceConversation, + Status: enums.TicketStatusPending, + CurrentAssigneeID: owner.ID, + ConversationID: 101, + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(3 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}, + }, + { + TicketNo: "TK202607050001", + Title: "退款进度跟进", + Description: "客户追问退款处理进度。", + Source: enums.TicketSourceConversation, + Status: enums.TicketStatusInProgress, + AuditFields: models.AuditFields{CreatedAt: dayStart.AddDate(0, 0, -1), UpdatedAt: dayStart.Add(2 * time.Hour)}, + }, + { + TicketNo: "TK202607060002", + Title: "已处理投诉", + Description: "客户投诉已处理完成。", + Source: enums.TicketSourceConversation, + Status: enums.TicketStatusDone, + HandledAt: &ticketHandledAt, + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour), UpdatedAt: dayStart.Add(time.Hour)}, + }, + } + for i := range tickets { + if err := db.Create(&tickets[i]).Error; err != nil { + t.Fatalf("create ticket: %v", err) + } + } + progress := []models.TicketProgress{ + {TicketID: tickets[0].ID, Content: "已电话联系客户,安排师傅明天上门检查。", AuthorID: owner.ID, CreatedAt: dayStart.Add(4*time.Hour + 30*time.Minute)}, + {TicketID: tickets[1].ID, Content: "已同步退款处理进度,等待财务确认。", AuthorID: owner.ID, CreatedAt: dayStart.Add(2*time.Hour + 30*time.Minute)}, + } + for i := range progress { + if err := db.Create(&progress[i]).Error; err != nil { + t.Fatalf("create ticket progress: %v", err) + } + } + feedbackLogs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: 66, Question: "老人腰不好怎么选床垫?", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), ModelName: "test-model", CreatedAt: dayStart.Add(10 * time.Hour)}, + {KnowledgeBaseID: 66, Question: "周末活动能叠加吗?", AnswerStatus: int(enums.KnowledgeAnswerStatusFallback), ModelName: "test-model", CreatedAt: dayStart.Add(11 * time.Hour)}, + {KnowledgeBaseID: 66, Question: "保修政策是什么?", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), ModelName: "test-model", CreatedAt: dayStart.Add(12 * time.Hour)}, + } + for i := range feedbackLogs { + if err := db.Create(&feedbackLogs[i]).Error; err != nil { + t.Fatalf("create feedback retrieve log: %v", err) + } + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: feedbackLogs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "回答清楚", CreatedAt: dayStart.Add(9 * time.Hour)}, + {RetrieveLogID: feedbackLogs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "推荐不准确", CreatedAt: dayStart.Add(10 * time.Hour)}, + {RetrieveLogID: feedbackLogs[1].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeNotHelpful), FeedbackReason: "推荐不准确", CreatedAt: dayStart.Add(11 * time.Hour)}, + {RetrieveLogID: feedbackLogs[2].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeWrongCitation), FeedbackReason: "", CreatedAt: dayStart.Add(12 * time.Hour)}, + {RetrieveLogID: feedbackLogs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "昨天反馈", CreatedAt: dayStart.AddDate(0, 0, -1)}, + } + for i := range feedbacks { + if err := db.Create(&feedbacks[i]).Error; err != nil { + t.Fatalf("create feedback: %v", err) + } + } + faqDrafts := []models.KnowledgeFAQ{ + { + KnowledgeBaseID: 66, + Question: "保修政策是什么?", + Answer: "待补充标准答案", + Status: enums.StatusDisabled, + Remark: "由知识检索日志生成的待确认 FAQ 草稿\n来源检索日志:3", + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(13 * time.Hour), UpdatedAt: dayStart.Add(13 * time.Hour)}, + }, + { + KnowledgeBaseID: 66, + Question: "周末活动能叠加吗?", + Answer: "待补充标准答案", + Status: enums.StatusDisabled, + Remark: "由知识检索日志生成的待确认 FAQ 草稿\n来源检索日志:2", + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(12*time.Hour + 30*time.Minute), UpdatedAt: dayStart.Add(12*time.Hour + 30*time.Minute)}, + }, + { + KnowledgeBaseID: 66, + Question: "已经启用的草稿不再待确认", + Answer: "已确认答案", + Status: enums.StatusOk, + Remark: "由知识检索日志生成的待确认 FAQ 草稿\n来源检索日志:999", + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(11 * time.Hour), UpdatedAt: dayStart.Add(11 * time.Hour)}, + }, + { + KnowledgeBaseID: 66, + Question: "普通停用 FAQ 不进入日报草稿提醒", + Answer: "停用", + Status: enums.StatusDisabled, + Remark: "人工停用", + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(10 * time.Hour), UpdatedAt: dayStart.Add(10 * time.Hour)}, + }, + } + for i := range faqDrafts { + if err := db.Create(&faqDrafts[i]).Error; err != nil { + t.Fatalf("create faq draft: %v", err) + } + } + + report := DashboardService.GetDailyBusinessReport("2026-07-06", i18nx.LocaleZhCN) + if report.OverdueFollowUpCount != 1 || report.TodayFollowUpCount != 1 || report.UnscheduledHotLeads != 3 { + t.Fatalf("unexpected follow-up counts: %#v", report) + } + if report.OverdueAppointmentCount != 1 || report.TodayAppointmentCount != 1 || report.UnscheduledAppointmentCount != 1 { + t.Fatalf("unexpected appointment counts: %#v", report) + } + if report.ConvertedCount != 1 { + t.Fatalf("unexpected converted count: %#v", report) + } + if report.UnassignedPriorityLeadCount != 6 { + t.Fatalf("unexpected unassigned priority lead count: %#v", report) + } + if report.PendingAfterSalesTicketCount != 2 || report.TodayAfterSalesTicketCount != 2 || report.TodayHandledAfterSalesTicketCount != 1 { + t.Fatalf("unexpected after-sales ticket counts: %#v", report) + } + if len(report.AfterSalesTickets) != 2 || report.AfterSalesTickets[0].TicketNo != "TK202607060001" || report.AfterSalesTickets[0].CurrentAssigneeName != "顾问A" { + t.Fatalf("unexpected after-sales tickets: %#v", report.AfterSalesTickets) + } + if !strings.Contains(report.AfterSalesTickets[0].LatestProgress, "安排师傅明天上门检查") || report.AfterSalesTickets[0].LatestProgressAt == "" { + t.Fatalf("unexpected after-sales ticket progress: %#v", report.AfterSalesTickets[0]) + } + if report.AIFeedbackCount != 4 || report.AIFeedbackLikeCount != 1 || report.AIFeedbackNegativeCount != 3 || report.AIFeedbackNegativeRate != 75 { + t.Fatalf("unexpected ai feedback stats: %#v", report) + } + if len(report.TopAIFeedbackReasons) != 2 || report.TopAIFeedbackReasons[0].Name != "推荐不准确" || report.TopAIFeedbackReasons[0].Count != 2 { + t.Fatalf("unexpected ai feedback reasons: %#v", report.TopAIFeedbackReasons) + } + if len(report.RecentNegativeAIFeedbacks) != 3 { + t.Fatalf("unexpected recent negative ai feedbacks: %#v", report.RecentNegativeAIFeedbacks) + } + if report.PendingFAQDraftCount != 2 || len(report.PendingFAQDrafts) != 2 { + t.Fatalf("unexpected pending faq drafts: count=%d drafts=%#v", report.PendingFAQDraftCount, report.PendingFAQDrafts) + } + if report.PendingFAQDrafts[0].Question != "保修政策是什么?" || report.PendingFAQDrafts[0].KnowledgeBaseID != 66 { + t.Fatalf("unexpected first pending faq draft: %#v", report.PendingFAQDrafts[0]) + } + if report.RecentNegativeAIFeedbacks[0].RetrieveLogID != feedbackLogs[2].ID || + report.RecentNegativeAIFeedbacks[0].KnowledgeBaseID != 66 || + report.RecentNegativeAIFeedbacks[0].Question != "保修政策是什么?" || + report.RecentNegativeAIFeedbacks[0].FeedbackTypeName != "引用错误" { + t.Fatalf("unexpected first negative ai feedback: %#v", report.RecentNegativeAIFeedbacks[0]) + } + if len(report.PriorityFollowUps) != 5 { + t.Fatalf("expected 5 priority follow-ups, got %#v", report.PriorityFollowUps) + } + if report.PriorityFollowUps[0].CustomerName != "逾期客户" || report.PriorityFollowUps[0].FollowUpState != "overdue" || report.PriorityFollowUps[0].OwnerUserName != "顾问A" { + t.Fatalf("unexpected first priority follow-up: %#v", report.PriorityFollowUps[0]) + } + if !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "已逾期未跟进") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "还没设置下次跟进时间") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "预约已逾期未到店") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "预约到店") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "预约意向还没确认") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "重点线索未分配负责人") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "售后/投诉工单未处理") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "今日新增 2 个售后/投诉工单") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "今日已处理 1 个售后/投诉工单") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "AI 回答负反馈") || + !strings.Contains(strings.Join(report.FollowUpSuggestions, "\n"), "FAQ 草稿待确认") { + t.Fatalf("follow-up suggestions missing priority guidance: %#v", report.FollowUpSuggestions) + } + if !strings.Contains(report.Summary, "成交线索 1 条") || + !strings.Contains(strings.Join(report.Highlights, "\n"), "已标记成交 1 条线索") || + !strings.Contains(strings.Join(report.Highlights, "\n"), "还有 6 条重点线索未分配负责人") || + !strings.Contains(strings.Join(report.Highlights, "\n"), "还有 2 个售后/投诉工单未处理,今日已处理 1 个") || + !strings.Contains(strings.Join(report.Highlights, "\n"), "负反馈率 75.0%") { + t.Fatalf("report missing converted guidance: summary=%s highlights=%#v", report.Summary, report.Highlights) + } + if !strings.Contains(strings.Join(report.KnowledgeSuggestions, "\n"), "推荐不准确") || + !strings.Contains(strings.Join(report.KnowledgeSuggestions, "\n"), "FAQ 草稿待确认") { + t.Fatalf("knowledge suggestions missing ai feedback guidance: %#v", report.KnowledgeSuggestions) + } +} + +func TestAIQualityReportBuildsTodosAndRiskSamples(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeFeedback{}, + &models.KnowledgeFAQ{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + now := time.Now() + logs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: 88, Question: "床垫保修多久?", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), HitCount: 2, TopScore: 0.91, ModelName: "test-model", CreatedAt: now.Add(-2 * time.Hour)}, + {KnowledgeBaseID: 88, Question: "活动能不能叠加?", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), HitCount: 0, TopScore: 0.12, ModelName: "test-model", CreatedAt: now.Add(-90 * time.Minute)}, + {KnowledgeBaseID: 88, Question: "能保证治好腰疼吗?", AnswerStatus: int(enums.KnowledgeAnswerStatusBlocked), HitCount: 1, TopScore: 0.52, ModelName: "test-model", CreatedAt: now.Add(-80 * time.Minute)}, + {KnowledgeBaseID: 88, Question: "周末到店礼是什么?", AnswerStatus: int(enums.KnowledgeAnswerStatusFallback), HitCount: 1, TopScore: 0.44, ModelName: "test-model", CreatedAt: now.Add(-70 * time.Minute)}, + {KnowledgeBaseID: 88, Question: "旧问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), HitCount: 0, TopScore: 0.1, ModelName: "test-model", CreatedAt: now.AddDate(0, 0, -40)}, + } + for i := range logs { + if err := db.Create(&logs[i]).Error; err != nil { + t.Fatalf("create retrieve log: %v", err) + } + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: logs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "清楚", CreatedAt: now.Add(-2 * time.Hour)}, + {RetrieveLogID: logs[1].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "没有回答活动", CreatedAt: now.Add(-80 * time.Minute)}, + {RetrieveLogID: logs[2].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeWrongCitation), FeedbackReason: "引用错误", CreatedAt: now.Add(-70 * time.Minute)}, + } + for i := range feedbacks { + if err := db.Create(&feedbacks[i]).Error; err != nil { + t.Fatalf("create feedback: %v", err) + } + } + if err := db.Create(&models.KnowledgeFAQ{ + KnowledgeBaseID: 88, + Question: "活动能不能叠加?", + Answer: "待补充", + Status: enums.StatusDisabled, + Remark: "由知识检索日志生成的待确认 FAQ 草稿\n来源检索日志:2", + AuditFields: models.AuditFields{CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour)}, + }).Error; err != nil { + t.Fatalf("create faq draft: %v", err) + } + + report := DashboardService.GetAIQualityReport("30d", i18nx.LocaleZhCN) + if report.RetrieveTotal != 4 || report.RetrieveHitTotal != 3 || report.RetrieveHitRate != 75 { + t.Fatalf("unexpected retrieve stats: %#v", report) + } + if report.NoAnswerCount != 1 || report.FallbackCount != 1 || report.BlockedCount != 1 || report.RiskAnswerCount != 3 { + t.Fatalf("unexpected risk answer stats: %#v", report) + } + if report.FeedbackCount != 3 || report.NegativeFeedbackCount != 2 || report.NegativeFeedbackRate != 66.7 { + t.Fatalf("unexpected feedback stats: %#v", report) + } + if report.PendingFAQDraftCount != 1 || len(report.PendingFAQDrafts) != 1 { + t.Fatalf("unexpected faq draft stats: %#v", report) + } + if report.TodoTotal != 5 || len(report.Todos) != 5 { + t.Fatalf("unexpected quality todos: %#v", report.Todos) + } + if len(report.RecentRiskAnswerSamples) != 3 || report.RecentRiskAnswerSamples[0].ActionHref == "" { + t.Fatalf("unexpected risk samples: %#v", report.RecentRiskAnswerSamples) + } + if len(report.UnansweredQuestions) == 0 || report.UnansweredQuestions[0].Count == 0 { + t.Fatalf("expected unanswered questions: %#v", report.UnansweredQuestions) + } + foundPendingQuestion := false + for _, item := range report.PendingQuestionGroups { + if item.Question == "活动能不能叠加" { + foundPendingQuestion = true + if item.Count != 2 || item.NoAnswerCount != 1 || item.NegativeFeedbackCount != 1 || item.ActionHref == "" { + t.Fatalf("unexpected pending question group: %#v", item) + } + break + } + } + if !foundPendingQuestion { + t.Fatalf("expected pending question groups to include activity question: %#v", report.PendingQuestionGroups) + } + if !strings.Contains(strings.Join(report.KnowledgeSuggestions, "\n"), "负反馈") { + t.Fatalf("expected ai quality suggestions: %#v", report.KnowledgeSuggestions) + } +} + +func TestSalesFunnelReportIncludesAdvisorEfficiency(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Conversation{}, + &models.SalesLead{}, + &models.LeadFollowUp{}, + &models.User{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + now := time.Now() + dayStart := startOfDay(now) + advisorA := models.User{Username: "advisor-a", Nickname: "顾问A", Status: enums.StatusOk} + advisorB := models.User{Username: "advisor-b", Nickname: "顾问B", Status: enums.StatusOk} + if err := db.Create(&advisorA).Error; err != nil { + t.Fatalf("create advisor a: %v", err) + } + if err := db.Create(&advisorB).Error; err != nil { + t.Fatalf("create advisor b: %v", err) + } + for i := 0; i < 5; i++ { + item := models.Conversation{ + CustomerName: fmt.Sprintf("客户%d", i+1), + Status: enums.IMConversationStatusClosed, + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Duration(i) * time.Hour), UpdatedAt: dayStart.Add(time.Duration(i) * time.Hour)}, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + } + appointmentAt := dayStart.Add(28 * time.Hour) + overdueFollowUp := dayStart.Add(-2 * time.Hour) + leads := []models.SalesLead{ + {CustomerName: "成交客户", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageReadyToBuy, Status: enums.SalesLeadStatusConverted, OwnerUserID: advisorA.ID, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(30 * time.Minute), UpdatedAt: dayStart.Add(6 * time.Hour)}}, + {CustomerName: "逾期预约客户", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentAt, Status: enums.SalesLeadStatusFollowing, OwnerUserID: advisorA.ID, NextFollowUpAt: &overdueFollowUp, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(1 * time.Hour), UpdatedAt: dayStart.Add(2 * time.Hour)}}, + {CustomerName: "已到店客户", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentAt, Status: enums.SalesLeadStatusVisited, OwnerUserID: advisorA.ID, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(90 * time.Minute), UpdatedAt: dayStart.Add(5 * time.Hour)}}, + {CustomerName: "无效客户", IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusInvalid, OwnerUserID: advisorB.ID, Remark: "客户预算太低,价格超预算", AuditFields: models.AuditFields{CreatedAt: dayStart.Add(2 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}}, + {CustomerName: "准成交客户", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageReadyToBuy, Status: enums.SalesLeadStatusFollowing, OwnerUserID: advisorB.ID, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(3 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}}, + {CustomerName: "未分配客户", IntentLevel: enums.SalesLeadIntentLow, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusNew, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(4 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}}, + {CustomerName: "过期老线索", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageReadyToBuy, Status: enums.SalesLeadStatusConverted, OwnerUserID: advisorA.ID, AuditFields: models.AuditFields{CreatedAt: dayStart.AddDate(0, 0, -40), UpdatedAt: dayStart.AddDate(0, 0, -39)}}, + } + for i := range leads { + if err := db.Create(&leads[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + followUps := []models.LeadFollowUp{ + {LeadID: leads[0].ID, OperatorID: advisorA.ID, OperatorName: "顾问A", Content: "首跟进", CreatedAt: leads[0].CreatedAt.Add(30 * time.Minute)}, + {LeadID: leads[1].ID, OperatorID: advisorA.ID, OperatorName: "顾问A", Content: "首跟进", CreatedAt: leads[1].CreatedAt.Add(90 * time.Minute)}, + {LeadID: leads[2].ID, OperatorID: advisorB.ID, OperatorName: "顾问B", Content: "首跟进", CreatedAt: leads[2].CreatedAt.Add(120 * time.Minute)}, + } + for i := range followUps { + if err := db.Create(&followUps[i]).Error; err != nil { + t.Fatalf("create follow-up: %v", err) + } + } + + report := DashboardService.GetSalesFunnelReport("30d", i18nx.LocaleZhCN) + if report.ConversationTotal != 5 || report.LeadTotal != 6 || report.LeadConversionRate != 120 || report.ClosedConversionRate != 16.7 { + t.Fatalf("unexpected funnel totals: %#v", report) + } + if report.AppointmentTotal != 4 || report.VisitedTotal != 2 || report.ConvertedTotal != 1 || report.InvalidTotal != 1 || report.UnassignedTotal != 1 || report.OverdueFollowUpTotal != 1 { + t.Fatalf("unexpected funnel risk totals: %#v", report) + } + if len(report.InvalidReasons) != 1 || report.InvalidReasons[0].Name != "预算不匹配" || report.InvalidReasons[0].Count != 1 { + t.Fatalf("unexpected invalid reasons: %#v", report.InvalidReasons) + } + if len(report.Steps) != 7 || report.Steps[0].Key != "consultation" || report.Steps[4].Key != "visited" || report.Steps[6].Key != "converted" { + t.Fatalf("unexpected funnel steps: %#v", report.Steps) + } + if len(report.AdvisorStats) != 3 { + t.Fatalf("unexpected advisor stats: %#v", report.AdvisorStats) + } + if report.AdvisorStats[0].OwnerUserName != "顾问A" || + report.AdvisorStats[0].AssignedLeadCount != 3 || + report.AdvisorStats[0].ConvertedLeadCount != 1 || + report.AdvisorStats[0].OverdueFollowUpCount != 1 || + report.AdvisorStats[0].AverageFirstFollowUpMinutes != 80 { + t.Fatalf("unexpected first advisor stats: %#v", report.AdvisorStats[0]) + } + var advisorBStats response.DashboardAdvisorEfficiency + for _, item := range report.AdvisorStats { + if item.OwnerUserID == advisorB.ID { + advisorBStats = item + break + } + } + if advisorBStats.OwnerUserID == 0 || len(advisorBStats.InvalidReasons) != 1 || advisorBStats.InvalidReasons[0].Name != "预算不匹配" { + t.Fatalf("unexpected advisor invalid reasons: %#v", advisorBStats) + } + if !strings.Contains(strings.Join(report.Suggestions, "\n"), "未分配") || + !strings.Contains(strings.Join(report.Suggestions, "\n"), "逾期") { + t.Fatalf("expected funnel suggestions: %#v", report.Suggestions) + } +} + +func TestBusinessTrendReportBuildsSeriesAndTopItems(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Conversation{}, + &models.SalesLead{}, + &models.LeadFollowUp{}, + &models.User{}, + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeFeedback{}, + &models.KnowledgeFAQ{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + now := time.Now() + dayStart := startOfDay(now) + yesterday := dayStart.AddDate(0, 0, -1) + handoffAt := dayStart.Add(2 * time.Hour) + advisor := models.User{Username: "trend-advisor", Nickname: "趋势顾问", Status: enums.StatusOk} + if err := db.Create(&advisor).Error; err != nil { + t.Fatalf("create advisor: %v", err) + } + conversations := []models.Conversation{ + {CustomerName: "今日咨询1", Status: enums.IMConversationStatusClosed, HandoffAt: &handoffAt, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour), UpdatedAt: dayStart.Add(time.Hour)}}, + {CustomerName: "今日咨询2", Status: enums.IMConversationStatusActive, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(2 * time.Hour), UpdatedAt: dayStart.Add(2 * time.Hour)}}, + {CustomerName: "昨日咨询", Status: enums.IMConversationStatusClosed, AuditFields: models.AuditFields{CreatedAt: yesterday.Add(time.Hour), UpdatedAt: yesterday.Add(time.Hour)}}, + } + for i := range conversations { + if err := db.Create(&conversations[i]).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + } + appointmentAt := dayStart.Add(28 * time.Hour) + leads := []models.SalesLead{ + {CustomerName: "成交客户", InterestedProducts: "智能床垫, 护脊枕", SourceChannel: "官网", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageReadyToBuy, Status: enums.SalesLeadStatusConverted, OwnerUserID: advisor.ID, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour), UpdatedAt: dayStart.Add(3 * time.Hour)}}, + {CustomerName: "预约客户", InterestedProducts: "智能床垫", SourceChannel: "企微", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentAt, Status: enums.SalesLeadStatusFollowing, OwnerUserID: advisor.ID, AuditFields: models.AuditFields{CreatedAt: yesterday.Add(2 * time.Hour), UpdatedAt: yesterday.Add(3 * time.Hour)}}, + {CustomerName: "普通客户", InterestedProducts: "儿童床垫", SourceChannel: "官网", IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusNew, AuditFields: models.AuditFields{CreatedAt: yesterday.Add(3 * time.Hour), UpdatedAt: yesterday.Add(3 * time.Hour)}}, + {CustomerName: "关闭客户", InterestedProducts: "不计入", SourceChannel: "官网", IntentLevel: enums.SalesLeadIntentLow, Status: enums.SalesLeadStatusClosed, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(4 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}}, + } + for i := range leads { + if err := db.Create(&leads[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + if err := db.Create(&models.LeadFollowUp{ + LeadID: leads[0].ID, + OperatorID: advisor.ID, + OperatorName: "趋势顾问", + Content: "首跟进", + CreatedAt: leads[0].CreatedAt.Add(45 * time.Minute), + }).Error; err != nil { + t.Fatalf("create follow-up: %v", err) + } + logs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: 1, Question: "智能床垫适合老人吗?", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: dayStart.Add(time.Hour)}, + {KnowledgeBaseID: 1, Question: "周末活动能叠加吗?", AnswerStatus: int(enums.KnowledgeAnswerStatusFallback), CreatedAt: dayStart.Add(2 * time.Hour)}, + } + for i := range logs { + if err := db.Create(&logs[i]).Error; err != nil { + t.Fatalf("create retrieve log: %v", err) + } + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: logs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "清楚", CreatedAt: dayStart.Add(time.Hour)}, + {RetrieveLogID: logs[1].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "活动说错", CreatedAt: dayStart.Add(2 * time.Hour)}, + } + for i := range feedbacks { + if err := db.Create(&feedbacks[i]).Error; err != nil { + t.Fatalf("create feedback: %v", err) + } + } + if err := db.Create(&models.KnowledgeFAQ{ + KnowledgeBaseID: 1, + Question: "周末活动能叠加吗?", + Answer: "待确认", + Status: enums.StatusDisabled, + Remark: "由知识检索日志生成的待确认 FAQ 草稿\n来源检索日志:2", + AuditFields: models.AuditFields{CreatedAt: dayStart.Add(3 * time.Hour), UpdatedAt: dayStart.Add(3 * time.Hour)}, + }).Error; err != nil { + t.Fatalf("create faq draft: %v", err) + } + + report := DashboardService.GetBusinessTrendReport("7d", i18nx.LocaleZhCN) + if report.ConversationTotal != 3 || report.LeadTotal != 3 || report.VisitedTotal != 1 || report.ConvertedTotal != 1 || report.HandoffTotal != 1 { + t.Fatalf("unexpected trend totals: %#v", report) + } + if report.LeadConversionRate != 100 || report.HighIntentTotal != 2 || report.AppointmentTotal != 2 { + t.Fatalf("unexpected trend conversion: %#v", report) + } + if report.NegativeFeedbackTotal != 1 || report.PendingFAQDraftCount != 1 { + t.Fatalf("unexpected quality totals: %#v", report) + } + if len(report.Series) != 7 || report.Series[len(report.Series)-1].Date != dayStart.Format("2006-01-02") { + t.Fatalf("unexpected trend series: %#v", report.Series) + } + todayPoint := report.Series[len(report.Series)-1] + if todayPoint.ConversationCount != 2 || todayPoint.LeadCount != 1 || todayPoint.VisitedCount != 1 || todayPoint.ConvertedCount != 1 || todayPoint.HandoffCount != 1 || todayPoint.NegativeFeedbackCount != 1 { + t.Fatalf("unexpected today trend point: %#v", todayPoint) + } + if len(report.TopProducts) == 0 || report.TopProducts[0].Name != "智能床垫" || report.TopProducts[0].Count != 2 { + t.Fatalf("unexpected top products: %#v", report.TopProducts) + } + if len(report.TopChannels) == 0 || report.TopChannels[0].Name != "官网" { + t.Fatalf("unexpected top channels: %#v", report.TopChannels) + } + if len(report.TopUnansweredQuestions) == 0 || report.TopUnansweredQuestions[0].Name != "周末活动能叠加吗" { + t.Fatalf("unexpected unanswered questions: %#v", report.TopUnansweredQuestions) + } + if len(report.AdvisorStats) == 0 || report.AdvisorStats[0].OwnerUserName != "趋势顾问" { + t.Fatalf("unexpected advisor stats: %#v", report.AdvisorStats) + } + if !strings.Contains(strings.Join(report.Suggestions, "\n"), "负反馈") { + t.Fatalf("expected trend suggestions: %#v", report.Suggestions) + } + if !strings.Contains(report.ReportMarkdown, "周度经营趋势复盘") || + !strings.Contains(report.ReportMarkdown, "智能床垫") || + !strings.Contains(report.ReportMarkdown, "官网") || + !strings.Contains(report.ReportMarkdown, "趋势顾问") || + !strings.Contains(report.ReportMarkdown, "AI 负反馈") { + t.Fatalf("unexpected trend report markdown: %s", report.ReportMarkdown) + } +} + +func TestABTestReportComparesSourceChannelVariants(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.SalesLead{}, &models.KnowledgeFeedback{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + now := time.Now() + dayStart := startOfDay(now) + appointmentAt := dayStart.Add(28 * time.Hour) + leads := []models.SalesLead{ + {CustomerName: "A成交", SourceChannel: "opening_a", InterestedProducts: "智能床垫", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageReadyToBuy, Status: enums.SalesLeadStatusConverted, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(time.Hour), UpdatedAt: dayStart.Add(2 * time.Hour)}}, + {CustomerName: "A预约", SourceChannel: "opening_a", InterestedProducts: "智能床垫", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &appointmentAt, Status: enums.SalesLeadStatusFollowing, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(2 * time.Hour), UpdatedAt: dayStart.Add(3 * time.Hour)}}, + {CustomerName: "B普通", SourceChannel: "opening_b", InterestedProducts: "儿童床垫", IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusFollowing, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(3 * time.Hour), UpdatedAt: dayStart.Add(4 * time.Hour)}}, + {CustomerName: "B无效", SourceChannel: "opening_b", InterestedProducts: "儿童床垫", IntentLevel: enums.SalesLeadIntentLow, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusInvalid, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(4 * time.Hour), UpdatedAt: dayStart.Add(5 * time.Hour)}}, + {CustomerName: "未标记", InterestedProducts: "护脊枕", IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageConsulting, Status: enums.SalesLeadStatusNew, AuditFields: models.AuditFields{CreatedAt: dayStart.Add(5 * time.Hour), UpdatedAt: dayStart.Add(6 * time.Hour)}}, + } + for i := range leads { + if err := db.Create(&leads[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: 1001, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "清楚", CreatedAt: dayStart.Add(time.Hour)}, + {RetrieveLogID: 1002, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "推荐不准确", CreatedAt: dayStart.Add(2 * time.Hour)}, + {RetrieveLogID: 1003, FeedbackType: int(enums.KnowledgeFeedbackTypeNotHelpful), FeedbackReason: "没解决问题", CreatedAt: dayStart.Add(3 * time.Hour)}, + } + for i := range feedbacks { + if err := db.Create(&feedbacks[i]).Error; err != nil { + t.Fatalf("create feedback: %v", err) + } + } + + report := DashboardService.GetABTestReport("7d", i18nx.LocaleZhCN) + if report.LeadTotal != 5 || report.VariantTotal != 3 || len(report.Variants) != 3 { + t.Fatalf("unexpected ab report totals: %#v", report) + } + if report.FeedbackTotal != 3 || report.NegativeFeedbackTotal != 2 || report.NegativeFeedbackRate != 66.7 { + t.Fatalf("unexpected ab feedback guardrail: %#v", report) + } + if report.Variants[0].VariantCode != "opening_a" || + report.Variants[0].LeadCount != 2 || + report.Variants[0].HighIntentRate != 100 || + report.Variants[0].AppointmentRate != 100 || + report.Variants[0].VisitedCount != 1 || + report.Variants[0].VisitRate != 50 || + report.Variants[0].ConversionRate != 50 || + report.Variants[0].TopProduct != "智能床垫" { + t.Fatalf("unexpected top variant: %#v", report.Variants[0]) + } + var openingB = report.Variants[0] + foundOpeningB := false + for _, item := range report.Variants { + if item.VariantCode == "opening_b" { + openingB = item + foundOpeningB = true + break + } + } + if !foundOpeningB || openingB.InvalidRate != 50 || openingB.QualityRiskLevel != "medium" || openingB.QualityRiskReason == "" { + t.Fatalf("unexpected risk variant: %#v", openingB) + } + if !strings.Contains(strings.Join(report.Suggestions, "\n"), "opening a") { + t.Fatalf("expected ab suggestions to mention best variant: %#v", report.Suggestions) + } + if !strings.Contains(strings.Join(report.Suggestions, "\n"), "AI 负反馈 2 条") { + t.Fatalf("expected ab suggestions to mention quality guardrail: %#v", report.Suggestions) + } +} + +func TestSendDailyBusinessReportWebhook(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Conversation{}, + &models.Message{}, + &models.SalesLead{}, + &models.Product{}, + &models.Promotion{}, + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeFeedback{}, + &models.KnowledgeFAQ{}, + &models.User{}, + &models.Ticket{}, + &models.TicketProgress{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + reportDay := time.Date(2026, 7, 6, 9, 0, 0, 0, time.Local) + if err := db.Create(&models.Conversation{ + CustomerName: "日报客户", + Status: enums.IMConversationStatusClosed, + AuditFields: models.AuditFields{CreatedAt: reportDay, UpdatedAt: reportDay}, + }).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + if err := db.Create(&models.SalesLead{ + CustomerName: "成交客户", + Phone: "13800000000", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageReadyToBuy, + Status: enums.SalesLeadStatusConverted, + AuditFields: models.AuditFields{CreatedAt: reportDay, UpdatedAt: reportDay.Add(time.Hour)}, + }).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode webhook payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + DailyReport: config.DailyReportNotifyConfig{ + Enabled: true, + Cron: "0 9 * * *", + }, + }, + }) + t.Cleanup(func() { + config.SetCurrent(&config.Config{}) + }) + + resp, err := DashboardService.SendDailyBusinessReportWebhook("2026-07-06", i18nx.LocaleZhCN, 99) + if err != nil { + t.Fatalf("SendDailyBusinessReportWebhook() error = %v", err) + } + if !resp.Sent || !resp.WebhookEnabled || resp.WebhookEventType != "daily_business_report" { + t.Fatalf("unexpected push response: %#v", resp) + } + if got["eventType"] != "daily_business_report" || !strings.Contains(got["title"].(string), "2026-07-06") { + t.Fatalf("unexpected webhook payload: %#v", got) + } + if !strings.Contains(got["text"].(string), "成交客户") && !strings.Contains(got["text"].(string), "成交") { + t.Fatalf("webhook text should include daily business content: %s", got["text"]) + } + metadata := got["metadata"].(map[string]any) + if metadata["operatorId"].(float64) != 99 || metadata["convertedCount"].(float64) != 1 { + t.Fatalf("unexpected webhook metadata: %#v", metadata) + } +} + +func TestScheduledDailyBusinessReportSkipsDuplicateReportDate(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.Conversation{}, + &models.Message{}, + &models.SalesLead{}, + &models.Product{}, + &models.Promotion{}, + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeFeedback{}, + &models.KnowledgeFAQ{}, + &models.User{}, + &models.Ticket{}, + &models.TicketProgress{}, + &models.SystemConfig{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + sendCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + DailyReport: config.DailyReportNotifyConfig{ + Enabled: true, + Cron: "0 9 * * *", + AllowDuplicate: false, + }, + }, + }) + t.Cleanup(func() { + config.SetCurrent(&config.Config{}) + }) + + first, err := DashboardService.SendScheduledDailyBusinessReportWebhook("2026-07-06", i18nx.LocaleZhCN) + if err != nil { + t.Fatalf("first scheduled daily report error = %v", err) + } + if !first.Sent || sendCount != 1 { + t.Fatalf("expected first scheduled report sent once, resp=%#v count=%d", first, sendCount) + } + second, err := DashboardService.SendScheduledDailyBusinessReportWebhook("2026-07-06", i18nx.LocaleZhCN) + if err != nil { + t.Fatalf("second scheduled daily report error = %v", err) + } + if second.Sent || sendCount != 1 || !strings.Contains(second.Message, "已跳过重复") { + t.Fatalf("expected duplicate scheduled report skipped, resp=%#v count=%d", second, sendCount) + } + + cfg := config.Current() + cfg.Notify.DailyReport.AllowDuplicate = true + config.SetCurrent(&cfg) + third, err := DashboardService.SendScheduledDailyBusinessReportWebhook("2026-07-06", i18nx.LocaleZhCN) + if err != nil { + t.Fatalf("allow duplicate scheduled daily report error = %v", err) + } + if !third.Sent || sendCount != 2 { + t.Fatalf("expected allow duplicate report sent, resp=%#v count=%d", third, sendCount) + } +} diff --git a/internal/services/digital_store_profile_service.go b/internal/services/digital_store_profile_service.go new file mode 100644 index 00000000..6e8855fd --- /dev/null +++ b/internal/services/digital_store_profile_service.go @@ -0,0 +1,3472 @@ +package services + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +const ( + digitalStoreProfileConfigKey = "digital_store.profile" + digitalStoreConfigGroup = "digital_store" + digitalStoreRuntimeSeedRemark = "digital-store-runtime-seed" + digitalStoreDefaultTeamName = "门店顾问组" + digitalStoreDefaultAgentCode = "digital_store_consultant" +) + +var DigitalStoreProfileService = newDigitalStoreProfileService() + +func newDigitalStoreProfileService() *digitalStoreProfileService { + return &digitalStoreProfileService{} +} + +type digitalStoreProfileService struct { +} + +type digitalStoreProfileConfig struct { + request.DigitalStoreProfileRequest + KnowledgeFAQID int64 `json:"knowledgeFAQId"` + TemplateCode string `json:"templateCode"` + TemplateVersion string `json:"templateVersion"` + TemplateAppliedAt string `json:"templateAppliedAt"` +} + +type digitalStoreTemplateBundle struct { + template response.DigitalStoreTemplateResponse + cfg digitalStoreProfileConfig + products []request.SaveProductRequest + promotions []request.SavePromotionRequest +} + +func (s *digitalStoreProfileService) GetProfile() response.DigitalStoreProfileResponse { + cfg := s.loadConfig() + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", digitalStoreProfileConfigKey) + ret := buildDigitalStoreProfileResponse(cfg) + if item != nil { + ret.UpdatedAt = utils.FormatTime(item.UpdatedAt) + } + return ret +} + +func (s *digitalStoreProfileService) ListTemplates() []response.DigitalStoreTemplateResponse { + return []response.DigitalStoreTemplateResponse{ + { + Code: "muse_bedding", + Name: "慕斯寝具门店", + Industry: "家居寝具", + Version: "1.0.0", + Description: "适合床垫、睡眠产品、家居门店,包含推荐话术、预约试躺和活动权益样板。", + }, + { + Code: "oral_clinic", + Name: "口腔门诊", + Industry: "口腔医疗", + Version: "1.0.0", + Description: "适合口腔诊所咨询接待,包含合规提醒、正畸/种植/儿童齿科/洁牙服务样板。", + }, + { + Code: "kids_english", + Name: "少儿英语培训", + Industry: "教育培训", + Version: "1.0.0", + Description: "适合少儿英语、学科辅导和兴趣课程机构,包含试听预约、课程推荐和保过提分禁用口径。", + }, + { + Code: "finance_advisor", + Name: "金融顾问咨询", + Industry: "金融服务", + Version: "1.0.0", + Description: "适合贷款、保险、理财咨询和企业金融服务,包含风险提示、敏感信息边界和持牌顾问转人工。", + }, + { + Code: "home_decoration", + Name: "家装装修门店", + Industry: "家装装修", + Version: "1.0.0", + Description: "适合整装、设计施工和建材门店,包含量房预约、方案推荐、报价边界和施工售后风险。", + }, + } +} + +func digitalStoreTemplateMetadata(templateCode string) response.DigitalStoreTemplateResponse { + templateCode = strings.TrimSpace(templateCode) + for _, item := range DigitalStoreProfileService.ListTemplates() { + if item.Code == templateCode || (templateCode == "" && item.Code == "muse_bedding") || (templateCode == "muse" && item.Code == "muse_bedding") { + return item + } + } + return response.DigitalStoreTemplateResponse{Code: templateCode} +} + +func (s *digitalStoreProfileService) ExportTemplate(templateCode string) (response.DigitalStoreTemplateExportResponse, error) { + bundle, err := s.buildBuiltinTemplateBundle(templateCode) + if err != nil { + return response.DigitalStoreTemplateExportResponse{}, err + } + return response.DigitalStoreTemplateExportResponse{ + SchemaVersion: "1.0", + ExportedAt: utils.FormatTime(time.Now()), + Template: bundle.template, + Profile: buildDigitalStoreProfileResponse(bundle.cfg), + Products: buildDigitalStoreTemplateProductResponses(bundle.products), + Promotions: buildDigitalStoreTemplatePromotionResponses(bundle.promotions), + RiskRules: buildDigitalStoreIndustryRiskRuleResponses(bundle.cfg), + AcceptanceItems: buildDigitalStoreAcceptanceItems(bundle.cfg), + }, nil +} + +func (s *digitalStoreProfileService) PreviewTemplate(templateCode string) (response.DigitalStoreTemplatePreviewResponse, error) { + bundle, err := s.buildBuiltinTemplateBundle(templateCode) + if err != nil { + return response.DigitalStoreTemplatePreviewResponse{}, err + } + return s.previewTemplateBundle(bundle), nil +} + +func (s *digitalStoreProfileService) PreviewImportedTemplate(req request.DigitalStoreTemplateImportRequest) (response.DigitalStoreTemplatePreviewResponse, error) { + bundle, err := s.buildImportedTemplateBundle(req) + if err != nil { + return response.DigitalStoreTemplatePreviewResponse{}, err + } + return s.previewTemplateBundle(bundle), nil +} + +func (s *digitalStoreProfileService) buildBuiltinTemplateBundle(templateCode string) (digitalStoreTemplateBundle, error) { + templateCode = strings.TrimSpace(templateCode) + if templateCode == "" { + templateCode = "muse_bedding" + } + cfg, err := digitalStoreTemplateProfile(templateCode) + if err != nil { + return digitalStoreTemplateBundle{}, err + } + template := digitalStoreTemplateMetadata(templateCode) + cfg.TemplateCode = template.Code + cfg.TemplateVersion = template.Version + products, _, _, err := productTemplateSeeds(templateCode) + if err != nil { + return digitalStoreTemplateBundle{}, err + } + promotions, err := promotionTemplateSeeds(templateCode, time.Now()) + if err != nil { + return digitalStoreTemplateBundle{}, err + } + return digitalStoreTemplateBundle{template: template, cfg: cfg, products: products, promotions: promotions}, nil +} + +func (s *digitalStoreProfileService) buildImportedTemplateBundle(req request.DigitalStoreTemplateImportRequest) (digitalStoreTemplateBundle, error) { + template := response.DigitalStoreTemplateResponse{ + Code: strings.TrimSpace(req.Template.Code), + Name: strings.TrimSpace(req.Template.Name), + Industry: strings.TrimSpace(req.Template.Industry), + Version: strings.TrimSpace(req.Template.Version), + Description: strings.TrimSpace(req.Template.Description), + } + if template.Code == "" { + return digitalStoreTemplateBundle{}, errorsx.InvalidParam("template code is required") + } + if template.Name == "" { + template.Name = template.Code + } + if template.Version == "" { + template.Version = "custom" + } + cfg, err := s.buildConfig(req.Profile) + if err != nil { + return digitalStoreTemplateBundle{}, err + } + if cfg.Industry == "" { + cfg.Industry = template.Industry + } + if template.Industry == "" { + template.Industry = cfg.Industry + } + cfg.TemplateCode = template.Code + cfg.TemplateVersion = template.Version + cfg.TemplateAppliedAt = "" + products := make([]request.SaveProductRequest, 0, len(req.Products)) + for _, item := range req.Products { + item.ID = 0 + item.Name = strings.TrimSpace(item.Name) + if item.Name == "" { + return digitalStoreTemplateBundle{}, errorsx.InvalidParam("template product name is required") + } + products = append(products, item) + } + promotions := make([]request.SavePromotionRequest, 0, len(req.Promotions)) + for _, item := range req.Promotions { + item.ID = 0 + item.Name = strings.TrimSpace(item.Name) + if item.Name == "" { + return digitalStoreTemplateBundle{}, errorsx.InvalidParam("template promotion name is required") + } + promotions = append(promotions, item) + } + if len(products) == 0 && len(promotions) == 0 { + return digitalStoreTemplateBundle{}, errorsx.InvalidParam("template must include products or promotions") + } + return digitalStoreTemplateBundle{template: template, cfg: cfg, products: products, promotions: promotions}, nil +} + +func (s *digitalStoreProfileService) previewTemplateBundle(bundle digitalStoreTemplateBundle) response.DigitalStoreTemplatePreviewResponse { + current := s.loadConfig() + ret := response.DigitalStoreTemplatePreviewResponse{ + Template: bundle.template, + Profile: buildDigitalStoreProfileResponse(bundle.cfg), + ProfileAction: "create", + Products: buildDigitalStoreTemplateProductPreviewItems(bundle.products), + Promotions: buildDigitalStoreTemplatePromotionPreviewItems(bundle.promotions), + RiskRules: buildDigitalStoreIndustryRiskRuleResponses(bundle.cfg), + AcceptanceItems: buildDigitalStoreAcceptanceItems(bundle.cfg), + } + ret.Warnings = buildDigitalStoreTemplatePreviewWarnings(current, ret.Template) + if current.Initialized { + ret.ProfileAction = "update" + } + for _, item := range ret.Products { + if item.Action == "update" { + ret.ProductUpdateTotal++ + } else { + ret.ProductCreateTotal++ + } + } + for _, item := range ret.Promotions { + if item.Action == "update" { + ret.PromotionUpdateTotal++ + } else { + ret.PromotionCreateTotal++ + } + } + return ret +} + +func buildDigitalStoreTemplateProductResponses(products []request.SaveProductRequest) []response.DigitalStoreTemplateProductResponse { + ret := make([]response.DigitalStoreTemplateProductResponse, 0, len(products)) + for _, item := range products { + ret = append(ret, response.DigitalStoreTemplateProductResponse{ + Name: item.Name, + Category: item.Category, + PriceMin: item.PriceMin, + PriceMax: item.PriceMax, + SellingPoints: item.SellingPoints, + SuitablePeople: item.SuitablePeople, + UnsuitablePeople: item.UnsuitablePeople, + Scenarios: item.Scenarios, + Specs: item.Specs, + IndustryAttributes: item.IndustryAttributes, + ImageURL: item.ImageURL, + Priority: item.Priority, + Status: item.Status, + Remark: item.Remark, + }) + } + return ret +} + +func buildDigitalStoreTemplateProductPreviewItems(products []request.SaveProductRequest) []response.DigitalStoreTemplatePreviewItem { + ret := make([]response.DigitalStoreTemplatePreviewItem, 0, len(products)) + for _, item := range products { + preview := response.DigitalStoreTemplatePreviewItem{ + Name: item.Name, + Action: "create", + Reason: "按产品名称未找到现有记录,将新建产品并同步 FAQ。", + } + if existing := repositories.ProductRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", item.Name).Where("status <> ?", enums.StatusDeleted)); existing != nil { + preview.Action = "update" + preview.ExistingID = existing.ID + preview.Reason = "按产品名称匹配到现有记录,将更新产品字段并重建 FAQ。" + } + ret = append(ret, preview) + } + return ret +} + +func buildDigitalStoreTemplatePromotionResponses(promotions []request.SavePromotionRequest) []response.DigitalStoreTemplatePromotionResponse { + ret := make([]response.DigitalStoreTemplatePromotionResponse, 0, len(promotions)) + for _, item := range promotions { + ret = append(ret, response.DigitalStoreTemplatePromotionResponse{ + Name: item.Name, + PromotionType: item.PromotionType, + Description: item.Description, + ApplicableProducts: item.ApplicableProducts, + StartAt: item.StartAt, + EndAt: item.EndAt, + DiscountRule: item.DiscountRule, + StoreBenefit: item.StoreBenefit, + AppointmentBenefit: item.AppointmentBenefit, + ScriptSuggestion: item.ScriptSuggestion, + Priority: item.Priority, + Status: item.Status, + Remark: item.Remark, + }) + } + return ret +} + +func buildDigitalStoreTemplatePromotionPreviewItems(promotions []request.SavePromotionRequest) []response.DigitalStoreTemplatePreviewItem { + ret := make([]response.DigitalStoreTemplatePreviewItem, 0, len(promotions)) + for _, item := range promotions { + preview := response.DigitalStoreTemplatePreviewItem{ + Name: item.Name, + Action: "create", + Reason: "按活动名称未找到现有记录,将新建活动并同步 FAQ。", + } + if existing := repositories.PromotionRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", item.Name).Where("status <> ?", enums.StatusDeleted)); existing != nil { + preview.Action = "update" + preview.ExistingID = existing.ID + preview.Reason = "按活动名称匹配到现有记录,将更新活动字段并重建 FAQ。" + } + ret = append(ret, preview) + } + return ret +} + +func buildDigitalStoreIndustryRiskRuleResponses(cfg digitalStoreProfileConfig) []response.DigitalStoreIndustryRiskRuleResponse { + specs := digitalStoreIndustryRiskRuleSpecs(cfg) + ret := make([]response.DigitalStoreIndustryRiskRuleResponse, 0, len(specs)) + for _, spec := range specs { + ret = append(ret, response.DigitalStoreIndustryRiskRuleResponse{ + Key: spec.key, + Label: spec.label, + ForbiddenClaims: append([]string{}, spec.forbiddenClaims...), + HandoffTriggers: append([]string{}, spec.handoffTriggers...), + }) + } + return ret +} + +type digitalStoreIndustryRiskRuleSpec struct { + key string + label string + forbiddenClaims []string + handoffTriggers []string +} + +func digitalStoreIndustryRiskRuleSpecs(cfg digitalStoreProfileConfig) []digitalStoreIndustryRiskRuleSpec { + key := normalizeDigitalStoreIndustryKey(cfg) + common := digitalStoreIndustryRiskRuleSpec{ + key: "common", + label: "通用高风险口径", + forbiddenClaims: []string{ + "不得承诺最低价、最终价、额外折扣、现货库存、退款退货、赔付、安装时效或绝对结果。", + "不得虚构资质、案例、证书、名额、排班、服务范围或资料中没有的优惠。", + }, + handoffTriggers: []string{ + "客户追问最终成交价、实时库存、售后争议、退款赔付、投诉差评或明确要求人工。", + "客户留下手机号、微信、预约时间、预算或表现出高意向到店信号。", + }, + } + var industry digitalStoreIndustryRiskRuleSpec + switch key { + case "medical": + industry = digitalStoreIndustryRiskRuleSpec{ + key: "medical", + label: "医疗健康行业", + forbiddenClaims: []string{ + "不得在线诊断、承诺治疗效果、无痛、一次解决、百分百成功、无需检查或固定治疗周期。", + "不得虚构医生资质、排班、医保报销、药品/器械适应症或手术安排。", + }, + handoffTriggers: []string{ + "客户出现急性疼痛、出血、肿胀、外伤、儿童急症或明显病情风险。", + "客户询问最终费用、治疗方案、医生时间、医保报销、退款退费或要求医生沟通。", + }, + } + case "education": + industry = digitalStoreIndustryRiskRuleSpec{ + key: "education", + label: "教育培训行业", + forbiddenClaims: []string{ + "不得承诺保过、提分幅度、录取结果、证书包拿、就业保障或名师一定授课。", + "不得虚构办学资质、师资履历、课程名额、考试政策、退费比例或补课承诺。", + }, + handoffTriggers: []string{ + "客户询问最终学费、退费、合同条款、升学/考试结果、课程排期或老师资质证明。", + "客户留下学生年级、考试目标、手机号、试听时间或明确要课程顾问联系。", + }, + } + case "finance": + industry = digitalStoreIndustryRiskRuleSpec{ + key: "finance", + label: "金融服务行业", + forbiddenClaims: []string{ + "不得承诺收益、保本、稳赚、贷款必批、利率最低、额度确定或投资回报。", + "不得诱导客户提供完整银行卡密码、验证码、身份证影像等高敏信息。", + }, + handoffTriggers: []string{ + "客户咨询具体利率、额度、收益、合同条款、风险评级、投诉或资金损失。", + "客户表达办理意向、留下联系方式或需要持牌顾问/人工合规确认。", + }, + } + case "home_decoration": + industry = digitalStoreIndustryRiskRuleSpec{ + key: "home_decoration", + label: "家装装修行业", + forbiddenClaims: []string{ + "不得承诺一口价、绝不增项、固定工期、材料绝对环保、施工零风险或赔付金额。", + "不得虚构设计师资质、施工案例、材料品牌授权、排期、优惠名额或验收结论。", + }, + handoffTriggers: []string{ + "客户询问最终报价、工期、合同、材料品牌、增项争议、退款赔付或施工投诉。", + "客户提供户型面积、装修预算、量房时间、手机号或明确要设计师联系。", + }, + } + case "bedding": + industry = digitalStoreIndustryRiskRuleSpec{ + key: "bedding", + label: "家居寝具行业", + forbiddenClaims: []string{ + "不得承诺治好腰疼、百分百改善睡眠、最低成交价、今天一定有现货或无条件退换。", + "不得虚构库存、安装时效、售后赔付、检测证书或活动叠加权益。", + }, + handoffTriggers: []string{ + "客户询问最终成交价、实时库存、配送安装、退换货、售后异响或投诉。", + "客户留下尺寸、预算、手机号、微信、到店时间或高意向试躺信号。", + }, + } + default: + industry = digitalStoreIndustryRiskRuleSpec{ + key: "general_consulting", + label: "通用咨询行业", + forbiddenClaims: []string{ + "不得承诺资料中没有的效果、价格、周期、名额、资质、案例、结果或售后政策。", + }, + handoffTriggers: []string{ + "客户询问合同、最终价格、售后争议、资质证明或需要人工确认的个性化问题。", + }, + } + } + return []digitalStoreIndustryRiskRuleSpec{common, industry} +} + +func normalizeDigitalStoreIndustryKey(cfg digitalStoreProfileConfig) string { + text := strings.ToLower(strings.TrimSpace(cfg.TemplateCode + " " + cfg.Industry + " " + cfg.BrandName)) + switch { + case strings.Contains(text, "oral") || strings.Contains(text, "clinic") || strings.Contains(text, "医疗") || strings.Contains(text, "口腔") || strings.Contains(text, "医美") || strings.Contains(text, "健康"): + return "medical" + case strings.Contains(text, "education") || strings.Contains(text, "培训") || strings.Contains(text, "教育") || strings.Contains(text, "课程") || strings.Contains(text, "升学"): + return "education" + case strings.Contains(text, "finance") || strings.Contains(text, "金融") || strings.Contains(text, "贷款") || strings.Contains(text, "保险") || strings.Contains(text, "理财"): + return "finance" + case strings.Contains(text, "home_decoration") || strings.Contains(text, "装修") || strings.Contains(text, "家装") || strings.Contains(text, "装饰") || strings.Contains(text, "设计施工"): + return "home_decoration" + case strings.Contains(text, "muse") || strings.Contains(text, "bedding") || strings.Contains(text, "寝具") || strings.Contains(text, "床垫") || strings.Contains(text, "家居"): + return "bedding" + default: + return "general" + } +} + +func buildDigitalStoreTemplatePreviewWarnings(current digitalStoreProfileConfig, target response.DigitalStoreTemplateResponse) []response.DigitalStoreTemplatePreviewWarning { + warnings := make([]response.DigitalStoreTemplatePreviewWarning, 0) + if current.Initialized { + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "profile_update", + Message: "当前店长资料已初始化,应用模板会更新品牌、人设、预约规则、转人工规则和禁用承诺。", + }) + } + if strings.TrimSpace(current.TemplateCode) != "" { + switch { + case current.TemplateCode == target.Code && current.TemplateVersion == target.Version: + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "same_template_version", + Message: "当前店铺已应用同版本模板 " + current.TemplateCode + " v" + current.TemplateVersion + ",再次应用会刷新模板字段。", + }) + case current.TemplateCode == target.Code: + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "template_version_change", + Message: "当前店铺来自模板 " + current.TemplateCode + " v" + valueOrDefault(current.TemplateVersion, "-") + ",本次将应用 v" + valueOrDefault(target.Version, "-") + "。", + }) + default: + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "template_code_change", + Message: "当前店铺来自模板 " + current.TemplateCode + ",本次将切换为 " + target.Code + ",请确认不会覆盖商家定制口径。", + }) + } + } + if current.KnowledgeBaseID > 0 { + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "knowledge_preserved", + Message: "现有知识库 ID 会保留,模板只会同步或更新相关 FAQ。", + }) + } + if strings.TrimSpace(current.EnterpriseWebhookURL) != "" { + warnings = append(warnings, response.DigitalStoreTemplatePreviewWarning{ + Key: "webhook_preserved", + Message: "店长资料中的企业 Webhook 地址会保留,不会被模板覆盖。", + }) + } + return warnings +} + +func (s *digitalStoreProfileService) GetSetupStatus() response.DigitalStoreSetupStatusResponse { + cfg := s.loadConfig() + ret := response.DigitalStoreSetupStatusResponse{ + ProfileInitialized: cfg.Initialized, + KnowledgeBaseID: cfg.KnowledgeBaseID, + KnowledgeFAQID: cfg.KnowledgeFAQID, + } + _ = sqls.DB().Model(&models.Product{}).Where("status <> ?", enums.StatusDeleted).Count(&ret.ProductTotal).Error + _ = sqls.DB().Model(&models.Promotion{}).Where("status <> ?", enums.StatusDeleted).Count(&ret.PromotionTotal).Error + ret.ProductKnowledgeSyncedTotal, ret.ProductKnowledgeUnsyncedTotal, ret.ProductKnowledgeFailedTotal = countDigitalStoreKnowledgeCoverage(&models.Product{}, ret.ProductTotal) + ret.PromotionKnowledgeSyncedTotal, ret.PromotionKnowledgeUnsyncedTotal, ret.PromotionKnowledgeFailedTotal = countDigitalStoreKnowledgeCoverage(&models.Promotion{}, ret.PromotionTotal) + if aiConfig := s.findActiveLLMConfig(); aiConfig != nil { + ret.LLMConfigID = aiConfig.ID + ret.LLMConfigName = aiConfig.Name + } + if embeddingConfig := s.findActiveEmbeddingConfig(); embeddingConfig != nil { + ret.EmbeddingConfigID = embeddingConfig.ID + ret.EmbeddingConfigName = embeddingConfig.Name + } + if agent := s.findDigitalStoreAgent(cfg); agent != nil { + ret.AgentID = agent.ID + ret.AgentName = agent.Name + ret.WorkflowPublished = agent.WorkflowVersionID > 0 + ret.HumanHandoff = buildDigitalStoreHumanHandoff(agent) + } + if channel := s.findWebChannel(cfg); channel != nil { + ret.WebChannelID = channel.ID + ret.WebChannelCode = channel.ChannelID + ret.WebChannelName = channel.Name + ret.WebEntry = buildDigitalStoreWebEntry(channel, "") + } + ret.ModelHealthChecks = s.BuildModelHealthChecks(ret) + ret.Ready = ret.ProfileInitialized && + ret.KnowledgeBaseID > 0 && + ret.KnowledgeFAQID > 0 && + ret.ProductTotal > 0 && + ret.PromotionTotal > 0 && + ret.ProductKnowledgeUnsyncedTotal == 0 && + ret.ProductKnowledgeFailedTotal == 0 && + ret.PromotionKnowledgeUnsyncedTotal == 0 && + ret.PromotionKnowledgeFailedTotal == 0 && + ret.LLMConfigID > 0 && + ret.EmbeddingConfigID > 0 && + ret.AgentID > 0 && + ret.WorkflowPublished && + ret.HumanHandoff.Ready && + ret.WebChannelID > 0 + ret.MissingSteps = buildDigitalStoreMissingSteps(ret) + return ret +} + +func (s *digitalStoreProfileService) GetKnowledgeAssistant() response.DigitalStoreKnowledgeAssistantResponse { + cfg := s.loadConfig() + kbID := cfg.KnowledgeBaseID + if kbID == 0 { + if resolved, err := resolveDigitalStoreKnowledgeBaseID(0); err == nil { + kbID = resolved + } + } + faqs := []models.KnowledgeFAQ{} + if kbID > 0 { + faqs = repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), kbID) + } + items := buildDigitalStoreKnowledgeAssistantItems(cfg, kbID, faqs) + ret := response.DigitalStoreKnowledgeAssistantResponse{ + GeneratedAt: utils.FormatTime(time.Now()), + Industry: valueOrDefault(cfg.Industry, "通用咨询"), + KnowledgeBaseID: kbID, + Items: items, + } + for _, item := range items { + if item.Covered { + ret.CoveredTotal++ + } else { + ret.MissingTotal++ + } + } + return ret +} + +func (s *digitalStoreProfileService) GetTemplateEffect() response.DigitalStoreTemplateEffectResponse { + cfg := s.loadConfig() + kbID := cfg.KnowledgeBaseID + if kbID == 0 { + if resolved, err := resolveDigitalStoreKnowledgeBaseID(0); err == nil { + kbID = resolved + } + } + const days = 30 + since := time.Now().AddDate(0, 0, -days) + ret := response.DigitalStoreTemplateEffectResponse{ + GeneratedAt: utils.FormatTime(time.Now()), + TemplateCode: cfg.TemplateCode, + TemplateVersion: cfg.TemplateVersion, + TemplateAppliedAt: cfg.TemplateAppliedAt, + Industry: valueOrDefault(cfg.Industry, "通用咨询"), + KnowledgeBaseID: kbID, + Days: days, + } + if kbID == 0 { + ret.Suggestions = []string{"当前店铺还没有绑定知识库,先完成店长配置和知识库同步后再观察模板效果。"} + ret.ImprovementMarkdown = buildDigitalStoreTemplateImprovementMarkdown(ret) + return ret + } + db := sqls.DB() + base := db.Model(&models.KnowledgeRetrieveLog{}). + Where("knowledge_base_id = ?", kbID). + Where("created_at >= ?", since) + _ = base.Count(&ret.RetrieveTotal).Error + _ = db.Model(&models.KnowledgeRetrieveLog{}). + Where("knowledge_base_id = ?", kbID). + Where("created_at >= ?", since). + Where("answer_status IN ?", []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + }). + Count(&ret.MissingQuestionTotal).Error + _ = db.Model(&models.KnowledgeFeedback{}). + Joins("JOIN knowledge_retrieve_logs ON knowledge_retrieve_logs.id = knowledge_feedbacks.retrieve_log_id"). + Where("knowledge_retrieve_logs.knowledge_base_id = ?", kbID). + Where("knowledge_feedbacks.created_at >= ?", since). + Where("knowledge_feedbacks.feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Count(&ret.NegativeFeedbackTotal).Error + ret.MissingQuestions = s.findTemplateEffectMissingQuestions(kbID, since, 6) + ret.NegativeFeedbacks = s.findTemplateEffectNegativeFeedbacks(kbID, since, 6) + ret.Suggestions = buildDigitalStoreTemplateEffectSuggestions(ret) + ret.ImprovementMarkdown = buildDigitalStoreTemplateImprovementMarkdown(ret) + return ret +} + +func (s *digitalStoreProfileService) findTemplateEffectMissingQuestions(kbID int64, since time.Time, limit int) []response.DigitalStoreTemplateEffectItem { + type row struct { + Question string + Count int64 + LatestAt string + RetrieveLogID int64 + AnswerStatus int + } + var rows []row + err := sqls.DB().Model(&models.KnowledgeRetrieveLog{}). + Select("TRIM(question) AS question, COUNT(*) AS count, MAX(created_at) AS latest_at, MAX(id) AS retrieve_log_id, MAX(answer_status) AS answer_status"). + Where("knowledge_base_id = ?", kbID). + Where("created_at >= ?", since). + Where("TRIM(question) <> ''"). + Where("answer_status IN ?", []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + }). + Group("TRIM(question)"). + Order("count DESC, latest_at DESC"). + Limit(limit). + Scan(&rows).Error + if err != nil { + return nil + } + ret := make([]response.DigitalStoreTemplateEffectItem, 0, len(rows)) + for _, item := range rows { + status := enums.KnowledgeAnswerStatus(item.AnswerStatus) + ret = append(ret, response.DigitalStoreTemplateEffectItem{ + Question: strings.TrimSpace(item.Question), + Count: item.Count, + LatestAt: formatDigitalStoreTemplateEffectTime(item.LatestAt), + AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(status), + ActionHref: digitalStoreRetrieveLogActionHref(item.RetrieveLogID, kbID), + ActionLabel: "查看日志", + CreateFAQActionHref: digitalStoreRetrieveLogActionHref(item.RetrieveLogID, kbID), + }) + } + return ret +} + +func (s *digitalStoreProfileService) findTemplateEffectNegativeFeedbacks(kbID int64, since time.Time, limit int) []response.DigitalStoreTemplateEffectItem { + type row struct { + Question string + Count int64 + LatestAt string + RetrieveLogID int64 + FeedbackType int + FeedbackReason string + AnswerStatus int + } + var rows []row + err := sqls.DB().Model(&models.KnowledgeFeedback{}). + Select("TRIM(knowledge_retrieve_logs.question) AS question, COUNT(*) AS count, MAX(knowledge_feedbacks.created_at) AS latest_at, MAX(knowledge_feedbacks.retrieve_log_id) AS retrieve_log_id, MAX(knowledge_feedbacks.feedback_type) AS feedback_type, MAX(knowledge_feedbacks.feedback_reason) AS feedback_reason, MAX(knowledge_retrieve_logs.answer_status) AS answer_status"). + Joins("JOIN knowledge_retrieve_logs ON knowledge_retrieve_logs.id = knowledge_feedbacks.retrieve_log_id"). + Where("knowledge_retrieve_logs.knowledge_base_id = ?", kbID). + Where("knowledge_feedbacks.created_at >= ?", since). + Where("knowledge_feedbacks.feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)). + Where("TRIM(knowledge_retrieve_logs.question) <> ''"). + Group("TRIM(knowledge_retrieve_logs.question)"). + Order("count DESC, latest_at DESC"). + Limit(limit). + Scan(&rows).Error + if err != nil { + return nil + } + ret := make([]response.DigitalStoreTemplateEffectItem, 0, len(rows)) + for _, item := range rows { + feedbackType := enums.KnowledgeFeedbackType(item.FeedbackType) + status := enums.KnowledgeAnswerStatus(item.AnswerStatus) + ret = append(ret, response.DigitalStoreTemplateEffectItem{ + Question: strings.TrimSpace(item.Question), + Count: item.Count, + LatestAt: formatDigitalStoreTemplateEffectTime(item.LatestAt), + FeedbackReason: strings.TrimSpace(item.FeedbackReason), + FeedbackTypeName: enums.GetKnowledgeFeedbackTypeLabel(feedbackType), + AnswerStatusName: enums.GetKnowledgeAnswerStatusLabel(status), + ActionHref: digitalStoreRetrieveLogActionHref(item.RetrieveLogID, kbID), + ActionLabel: "查看反馈", + CreateFAQActionHref: digitalStoreRetrieveLogActionHref(item.RetrieveLogID, kbID), + }) + } + return ret +} + +func buildDigitalStoreTemplateEffectSuggestions(report response.DigitalStoreTemplateEffectResponse) []string { + suggestions := make([]string, 0, 4) + if strings.TrimSpace(report.TemplateCode) == "" { + suggestions = append(suggestions, "当前店铺未记录行业模板来源,建议先应用或导入模板,后续才能把问题沉淀回模板版本。") + } + if report.MissingQuestionTotal == 0 && report.NegativeFeedbackTotal == 0 { + suggestions = append(suggestions, fmt.Sprintf("近 %d 天暂无明显模板缺口,可继续观察真实客户咨询。", report.Days)) + return suggestions + } + if report.MissingQuestionTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("近 %d 天有 %d 次无答案、兜底或风控问题,优先把高频问题补成 FAQ 并加入行业模板。", report.Days, report.MissingQuestionTotal)) + } + if report.NegativeFeedbackTotal > 0 { + suggestions = append(suggestions, fmt.Sprintf("近 %d 天有 %d 次负反馈,建议复核答案口径和引用来源,确认后更新模板 FAQ 或风险规则。", report.Days, report.NegativeFeedbackTotal)) + } + if len(report.MissingQuestions) > 0 || len(report.NegativeFeedbacks) > 0 { + suggestions = append(suggestions, "处理完成后导出行业模板 JSON,把新增 FAQ、产品字段或风险口径沉淀到下一家商家交付。") + } + return suggestions +} + +func buildDigitalStoreTemplateImprovementMarkdown(report response.DigitalStoreTemplateEffectResponse) string { + var builder strings.Builder + templateCode := strings.TrimSpace(report.TemplateCode) + if templateCode == "" { + templateCode = "未记录模板" + } + builder.WriteString(fmt.Sprintf("# 行业模板改进包:%s\n\n", templateCode)) + builder.WriteString("## 模板信息\n") + builder.WriteString(fmt.Sprintf("- 行业:%s\n", valueOrDefault(report.Industry, "通用咨询"))) + builder.WriteString(fmt.Sprintf("- 模板版本:%s\n", valueOrDefault(report.TemplateVersion, "未记录"))) + if strings.TrimSpace(report.TemplateAppliedAt) != "" { + builder.WriteString(fmt.Sprintf("- 应用时间:%s\n", report.TemplateAppliedAt)) + } + builder.WriteString(fmt.Sprintf("- 统计周期:近 %d 天\n", report.Days)) + builder.WriteString(fmt.Sprintf("- 生成时间:%s\n\n", report.GeneratedAt)) + + builder.WriteString("## 效果概览\n") + builder.WriteString(fmt.Sprintf("- 知识检索:%d\n", report.RetrieveTotal)) + builder.WriteString(fmt.Sprintf("- 知识缺口:%d\n", report.MissingQuestionTotal)) + builder.WriteString(fmt.Sprintf("- 负反馈:%d\n\n", report.NegativeFeedbackTotal)) + + builder.WriteString("## 待补 FAQ 清单\n") + if len(report.MissingQuestions) == 0 { + builder.WriteString("- 暂无高频无答案、兜底或风控问题\n") + } else { + for _, item := range report.MissingQuestions { + builder.WriteString(fmt.Sprintf("- %s(%d 次,%s,最近:%s)\n", + item.Question, + item.Count, + valueOrDefault(item.AnswerStatusName, "待处理"), + valueOrDefault(item.LatestAt, "-"), + )) + } + } + builder.WriteString("\n## 待修正回答/风险口径\n") + if len(report.NegativeFeedbacks) == 0 { + builder.WriteString("- 暂无高频负反馈\n") + } else { + for _, item := range report.NegativeFeedbacks { + reason := strings.TrimSpace(item.FeedbackReason) + if reason == "" { + reason = valueOrDefault(item.FeedbackTypeName, "未填写原因") + } + builder.WriteString(fmt.Sprintf("- %s(%d 次,原因:%s,最近:%s)\n", + item.Question, + item.Count, + reason, + valueOrDefault(item.LatestAt, "-"), + )) + } + } + builder.WriteString("\n## 模板迭代建议\n") + if len(report.Suggestions) == 0 { + builder.WriteString("- 暂无模板迭代建议\n") + } else { + for _, suggestion := range report.Suggestions { + builder.WriteString(fmt.Sprintf("- %s\n", suggestion)) + } + } + builder.WriteString("\n## 沉淀动作\n") + builder.WriteString("- 将待补 FAQ 确认成标准答案,启用并重建索引。\n") + builder.WriteString("- 将高频负反馈对应的禁用承诺、风险边界或引用来源写回行业模板。\n") + builder.WriteString("- 处理完成后导出行业模板 JSON,作为下一家同类商家的交付底稿。\n") + return strings.TrimSpace(builder.String()) +} + +func digitalStoreRetrieveLogActionHref(retrieveLogID int64, knowledgeBaseID int64) string { + if retrieveLogID <= 0 { + return "/dashboard/knowledge?tab=retrieveLogs" + } + params := fmt.Sprintf("tab=retrieveLogs&retrieveLogId=%d", retrieveLogID) + if knowledgeBaseID > 0 { + params += fmt.Sprintf("&knowledgeBaseId=%d", knowledgeBaseID) + } + return "/dashboard/knowledge?" + params +} + +func formatDigitalStoreTemplateEffectTime(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + return utils.FormatTime(parsed) + } + } + return value +} + +func (s *digitalStoreProfileService) GetMaintenanceStatus() response.DigitalStoreMaintenanceStatusResponse { + const backupRoot = "backups" + latest, warnings := findLatestDigitalStoreBackupSnapshot(backupRoot) + restoreBackupDir := backupRoot + "/<备份目录>" + if latest != nil { + restoreBackupDir = latest.Path + } + backupCommand := "scripts/backup-single-merchant.sh --output backups --compose docker-compose.yml" + restoreDryRunCommand := "scripts/restore-single-merchant.sh --backup-dir " + restoreBackupDir + " --compose docker-compose.yml --dry-run" + upgradeCommands := []string{ + backupCommand, + "git pull", + "docker compose --env-file .env.production up -d --build", + "scripts/check-single-merchant-deploy.sh docker/agent-desk.production.yaml docker-compose.yml", + } + ret := response.DigitalStoreMaintenanceStatusResponse{ + CheckedAt: utils.FormatTime(time.Now()), + Status: "ok", + BackupRoot: backupRoot, + BackupCommand: backupCommand, + RestoreDryRunCommand: restoreDryRunCommand, + UpgradeCommands: upgradeCommands, + UpgradeRunbook: buildDigitalStoreUpgradeRunbook(latest, backupCommand, restoreDryRunCommand, upgradeCommands), + LatestBackup: latest, + Warnings: warnings, + } + if len(warnings) > 0 { + ret.Status = "warning" + } + return ret +} + +func buildDigitalStoreUpgradeRunbook(latest *response.DigitalStoreBackupSnapshotResponse, backupCommand string, restoreDryRunCommand string, upgradeCommands []string) string { + latestBackupText := "未发现本地备份,升级前必须先执行备份。" + if latest != nil { + latestBackupText = fmt.Sprintf("%s(%s)", valueOrDefault(latest.Path, "-"), valueOrDefault(latest.CreatedAt, latest.Timestamp)) + } + lines := []string{ + "# 单商家升级 Runbook", + "", + "## 0. 当前备份状态", + "", + "- 最近备份:" + latestBackupText, + "- 恢复演练命令:" + restoreDryRunCommand, + "", + "## 1. 升级前必须备份", + "", + "```bash", + backupCommand, + "```", + "", + "## 2. 拉取代码并重建服务", + "", + "```bash", + } + lines = append(lines, upgradeCommands...) + lines = append(lines, + "```", + "", + "## 3. 升级后后台复验", + "", + "- 打开 `/dashboard/store-setup`,确认交付报告没有阻断项。", + "- 检查“模型与检索健康”:聊天模型、Embedding 模型、向量库、产品知识索引、活动知识索引均应通过。", + "- 如果产品或活动知识索引未同步,点击“同步店长知识”或进入产品/活动页重建 FAQ。", + "- 点击“发送关键通知测试”,确认高意向、预约、转人工、未分配和售后风险 5 类通知均成功。", + "", + "## 4. 客户入口复验", + "", + "```bash", + "MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs", + "```", + "", + "## 5. 异常回滚", + "", + "- 如果升级后聊天入口、知识检索或后台登录异常,先保留日志,再按最近备份执行 dry-run 恢复演练。", + "- dry-run 无异常后,停外部流量和应用服务,再使用 `scripts/restore-single-merchant.sh --confirm` 执行正式恢复。", + ) + return strings.Join(lines, "\n") +} + +func (s *digitalStoreProfileService) GetDeliveryReport(publicBaseURL string) response.DigitalStoreDeliveryReportResponse { + cfg := s.loadConfig() + status := s.GetSetupStatus() + baseURL := strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + dashboardURL := "" + if baseURL != "" { + dashboardURL = baseURL + "/dashboard" + } + webEntry := status.WebEntry + if channel := s.findWebChannel(cfg); channel != nil { + webEntry = buildDigitalStoreWebEntry(channel, baseURL) + } + items := []response.DigitalStoreDeliveryReportItem{ + buildDeliveryReportItem("品牌与门店", cfg.Initialized, valueOrDefault(cfg.BrandName, "-")+" / "+valueOrDefault(cfg.StoreName, "-")), + buildDeliveryReportItem("产品库", status.ProductTotal > 0, fmt.Sprintf("%d 个产品", status.ProductTotal)), + buildDeliveryReportItem("活动库", status.PromotionTotal > 0, fmt.Sprintf("%d 个活动", status.PromotionTotal)), + buildDeliveryReportItem("产品知识索引", status.ProductTotal > 0 && status.ProductKnowledgeUnsyncedTotal == 0 && status.ProductKnowledgeFailedTotal == 0, formatKnowledgeCoverage(status.ProductKnowledgeSyncedTotal, status.ProductTotal, status.ProductKnowledgeUnsyncedTotal, status.ProductKnowledgeFailedTotal)), + buildDeliveryReportItem("活动知识索引", status.PromotionTotal > 0 && status.PromotionKnowledgeUnsyncedTotal == 0 && status.PromotionKnowledgeFailedTotal == 0, formatKnowledgeCoverage(status.PromotionKnowledgeSyncedTotal, status.PromotionTotal, status.PromotionKnowledgeUnsyncedTotal, status.PromotionKnowledgeFailedTotal)), + buildDeliveryReportItem("聊天模型", status.LLMConfigID > 0, valueOrDefault(status.LLMConfigName, "-")), + buildDeliveryReportItem("Embedding 模型", status.EmbeddingConfigID > 0, valueOrDefault(status.EmbeddingConfigName, "-")), + buildDeliveryReportItem("知识库", status.KnowledgeBaseID > 0 && status.KnowledgeFAQID > 0, fmt.Sprintf("知识库 #%d / FAQ #%d", status.KnowledgeBaseID, status.KnowledgeFAQID)), + buildDeliveryReportItem("数字店长 Agent", status.AgentID > 0 && status.WorkflowPublished, valueOrDefault(status.AgentName, "-")), + buildDeliveryReportItem("人工接待配置", status.HumanHandoff.Ready, status.HumanHandoff.Message), + buildDeliveryReportItem("Web 聊天渠道", status.WebChannelID > 0, valueOrDefault(status.WebChannelCode, "-")), + buildDeliveryReportItem("客户入口品牌化", webEntry.ChannelCode != "" && webEntry.Title != "" && webEntry.ThemeColor != "", formatDigitalStoreWebEntry(webEntry)), + } + notificationStatus := s.GetNotificationStatus() + items = append(items, buildDeliveryReportItem("外部通知", notificationStatus.Enabled, notificationStatus.Message)) + securityChecks := s.GetSecurityChecks(notificationStatus) + items = append(items, buildDeliveryReportItem("上线安全自检", !hasBlockingSecurityCheck(securityChecks), formatSecurityCheckSummary(securityChecks))) + report := response.DigitalStoreDeliveryReportResponse{ + GeneratedAt: utils.FormatTime(time.Now()), + BrandName: cfg.BrandName, + StoreName: cfg.StoreName, + Ready: status.Ready, + DashboardURL: dashboardURL, + ChatURL: webEntry.ChatURL, + EmbedSnippet: webEntry.EmbedSnippet, + WebEntry: webEntry, + HumanHandoff: status.HumanHandoff, + AcceptanceCommand: defaultDigitalStoreAcceptanceCommand(cfg), + AcceptanceItems: buildDigitalStoreAcceptanceItems(cfg), + NotificationStatus: notificationStatus, + SecurityChecks: securityChecks, + ModelHealthChecks: status.ModelHealthChecks, + Items: items, + MissingSteps: status.MissingSteps, + LatestRecord: s.GetLatestDeliveryRecord(), + } + report.AcceptanceRunbook = buildDigitalStoreAcceptanceRunbook(report) + report.Markdown = buildDigitalStoreDeliveryReportMarkdown(report) + return report +} + +func countDigitalStoreKnowledgeCoverage(model any, total int64) (synced int64, unsynced int64, failed int64) { + db := sqls.DB() + base := func() *gorm.DB { + return db.Model(model).Where("status <> ?", enums.StatusDeleted) + } + _ = base().Where("knowledge_faq_id > 0").Count(&synced).Error + _ = base().Where("knowledge_faq_id = 0").Count(&unsynced).Error + if total > 0 && synced+unsynced < total { + unsynced += total - synced - unsynced + } + failedSubQuery := db.Model(&models.KnowledgeFAQ{}). + Select("id"). + Where("index_status = ?", enums.KnowledgeDocumentIndexStatusFailed). + Where("status <> ?", enums.StatusDeleted) + _ = base().Where("knowledge_faq_id IN (?)", failedSubQuery).Count(&failed).Error + return synced, unsynced, failed +} + +func findLatestDigitalStoreBackupSnapshot(root string) (*response.DigitalStoreBackupSnapshotResponse, []response.DigitalStoreMaintenanceWarningResponse) { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "." || root == "" { + root = "backups" + } + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, []response.DigitalStoreMaintenanceWarningResponse{ + buildDigitalStoreMaintenanceWarning("backup_missing", "暂无备份", "未发现 backups 目录,请先执行内置备份脚本并配置定时备份。"), + } + } + return nil, []response.DigitalStoreMaintenanceWarningResponse{ + buildDigitalStoreMaintenanceWarning("backup_scan_failed", "备份检查失败", "无法读取 "+root+" 目录:"+err.Error()), + } + } + snapshots := make([]response.DigitalStoreBackupSnapshotResponse, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(root, entry.Name()) + snapshot := buildDigitalStoreBackupSnapshot(path, entry.Name()) + snapshots = append(snapshots, snapshot) + } + if len(snapshots) == 0 { + return nil, []response.DigitalStoreMaintenanceWarningResponse{ + buildDigitalStoreMaintenanceWarning("backup_empty", "暂无备份", root+" 目录下没有可用备份快照。"), + } + } + sort.Slice(snapshots, func(i, j int) bool { + return digitalStoreBackupSortKey(snapshots[i]) > digitalStoreBackupSortKey(snapshots[j]) + }) + latest := snapshots[0] + warnings := make([]response.DigitalStoreMaintenanceWarningResponse, 0) + if !latest.HasManifest { + warnings = append(warnings, buildDigitalStoreMaintenanceWarning("manifest_missing", "备份清单缺失", "最近备份缺少 BACKUP-MANIFEST.txt,恢复前请人工确认来源。")) + } + if !latest.HasMySQLDump && !latest.HasDataArchive { + warnings = append(warnings, buildDigitalStoreMaintenanceWarning("data_snapshot_missing", "数据快照缺失", "最近备份未包含 mysql.sql 或 data.tar.gz,可能无法完整恢复业务数据。")) + } + if !latest.HasDockerConfigArchive || !latest.HasConfigSnapshot { + warnings = append(warnings, buildDigitalStoreMaintenanceWarning("config_snapshot_partial", "配置快照不完整", "最近备份未同时包含 docker 配置和 config/config.yaml,迁移机器时需另行保存部署配置。")) + } + return &latest, warnings +} + +func buildDigitalStoreBackupSnapshot(path string, fallbackTimestamp string) response.DigitalStoreBackupSnapshotResponse { + manifest := readDigitalStoreBackupManifest(filepath.Join(path, "BACKUP-MANIFEST.txt")) + timestamp := valueOrDefault(manifest["timestamp"], fallbackTimestamp) + snapshot := response.DigitalStoreBackupSnapshotResponse{ + Path: filepath.ToSlash(path), + Timestamp: timestamp, + CreatedAt: manifest["created_at"], + ProjectDir: manifest["project_dir"], + ComposeFile: manifest["compose_file"], + HasManifest: manifest != nil, + HasMySQLDump: fileExists(filepath.Join(path, "mysql.sql")), + HasDataArchive: fileExists(filepath.Join(path, "data.tar.gz")), + HasDockerConfigArchive: fileExists(filepath.Join(path, "docker-config.tar.gz")), + HasConfigSnapshot: fileExists(filepath.Join(path, "config", "config.yaml")), + } + snapshot.SizeBytes = directorySizeBytes(path) + return snapshot +} + +func readDigitalStoreBackupManifest(path string) map[string]string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + ret := map[string]string{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + key, value, ok := strings.Cut(scanner.Text(), "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + ret[key] = strings.TrimSpace(value) + } + return ret +} + +func digitalStoreBackupSortKey(snapshot response.DigitalStoreBackupSnapshotResponse) string { + if snapshot.CreatedAt != "" { + return snapshot.CreatedAt + } + return snapshot.Timestamp +} + +func directorySizeBytes(path string) int64 { + var total int64 + _ = filepath.WalkDir(path, func(_ string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + info, err := entry.Info() + if err == nil { + total += info.Size() + } + return nil + }) + return total +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func buildDigitalStoreMaintenanceWarning(key string, label string, message string) response.DigitalStoreMaintenanceWarningResponse { + return response.DigitalStoreMaintenanceWarningResponse{ + Key: key, + Label: label, + Message: message, + } +} + +func (s *digitalStoreProfileService) BuildModelHealthChecks(status response.DigitalStoreSetupStatusResponse) []response.DigitalStoreHealthCheckResponse { + checks := []response.DigitalStoreHealthCheckResponse{} + if status.LLMConfigID > 0 { + checks = append(checks, buildDigitalStoreHealthCheck("llm", "聊天模型", "ok", "已启用聊天模型:"+valueOrDefault(status.LLMConfigName, fmt.Sprintf("#%d", status.LLMConfigID)))) + if aiConfig := repositories.AIConfigRepository.Get(sqls.DB(), status.LLMConfigID); aiConfig != nil { + if aiConfig.TimeoutMS < 90000 || aiConfig.MaxRetryCount < 2 { + checks = append(checks, buildDigitalStoreHealthCheck("llm_runtime", "聊天模型稳定性", "warning", fmt.Sprintf("当前超时 %dms、重试 %d 次;商用交付建议超时不少于 90000ms 且重试不少于 2 次,并保留 AI 失败兜底回复。", aiConfig.TimeoutMS, aiConfig.MaxRetryCount))) + } else { + checks = append(checks, buildDigitalStoreHealthCheck("llm_runtime", "聊天模型稳定性", "ok", fmt.Sprintf("超时 %dms、重试 %d 次,满足连续咨询验收建议。", aiConfig.TimeoutMS, aiConfig.MaxRetryCount))) + } + } + } else { + checks = append(checks, buildDigitalStoreHealthCheck("llm", "聊天模型", "blocking", "未启用聊天模型,数字店长无法生成客户回复。")) + } + if status.EmbeddingConfigID > 0 { + checks = append(checks, buildDigitalStoreHealthCheck("embedding", "Embedding 模型", "ok", "已启用向量模型:"+valueOrDefault(status.EmbeddingConfigName, fmt.Sprintf("#%d", status.EmbeddingConfigID)))) + } else { + checks = append(checks, buildDigitalStoreHealthCheck("embedding", "Embedding 模型", "blocking", "未启用 Embedding 模型,产品、活动和 FAQ 无法稳定检索。")) + } + + vectorType := "" + if cfg, ok := safeRuntimeConfig(); ok { + vectorType = strings.ToLower(strings.TrimSpace(cfg.VectorDB.Type)) + } + switch vectorType { + case "qdrant": + checks = append(checks, buildDigitalStoreHealthCheck("vector_db", "向量库", "ok", "向量库类型为 qdrant,适合正式部署。")) + case "lancedb": + checks = append(checks, buildDigitalStoreHealthCheck("vector_db", "向量库", "ok", "向量库类型为 lancedb,适合轻量单商家部署。")) + case "": + checks = append(checks, buildDigitalStoreHealthCheck("vector_db", "向量库", "blocking", "vectorDB.type 未配置,知识检索可能不可用。")) + default: + checks = append(checks, buildDigitalStoreHealthCheck("vector_db", "向量库", "blocking", "vectorDB.type 为 "+vectorType+",请改为 qdrant 或 lancedb。")) + } + + productOK := status.ProductTotal > 0 && status.ProductKnowledgeUnsyncedTotal == 0 && status.ProductKnowledgeFailedTotal == 0 + checks = append(checks, buildDigitalStoreKnowledgeHealthCheck( + "product_index", + "产品知识索引", + productOK, + status.ProductTotal, + status.ProductKnowledgeSyncedTotal, + status.ProductKnowledgeUnsyncedTotal, + status.ProductKnowledgeFailedTotal, + )) + promotionOK := status.PromotionTotal > 0 && status.PromotionKnowledgeUnsyncedTotal == 0 && status.PromotionKnowledgeFailedTotal == 0 + checks = append(checks, buildDigitalStoreKnowledgeHealthCheck( + "promotion_index", + "活动知识索引", + promotionOK, + status.PromotionTotal, + status.PromotionKnowledgeSyncedTotal, + status.PromotionKnowledgeUnsyncedTotal, + status.PromotionKnowledgeFailedTotal, + )) + return checks +} + +func buildDigitalStoreKnowledgeHealthCheck(key string, label string, ok bool, total int64, synced int64, unsynced int64, failed int64) response.DigitalStoreHealthCheckResponse { + if total == 0 { + return buildDigitalStoreHealthCheck(key, label, "blocking", label+"没有可用数据,请先导入。") + } + if ok { + return buildDigitalStoreHealthCheck(key, label, "ok", fmt.Sprintf("已同步 %d/%d,索引状态正常。", synced, total)) + } + return buildDigitalStoreHealthCheck(key, label, "blocking", formatKnowledgeCoverage(synced, total, unsynced, failed)) +} + +type digitalStoreKnowledgeAssistantSpec struct { + key string + question string + reason string + keywords []string + required bool +} + +func buildDigitalStoreKnowledgeAssistantItems(cfg digitalStoreProfileConfig, knowledgeBaseID int64, faqs []models.KnowledgeFAQ) []response.DigitalStoreKnowledgeAssistantItem { + specs := digitalStoreKnowledgeAssistantSpecs(cfg) + items := make([]response.DigitalStoreKnowledgeAssistantItem, 0, len(specs)) + for _, spec := range specs { + item := response.DigitalStoreKnowledgeAssistantItem{ + Key: spec.key, + Question: spec.question, + Reason: spec.reason, + Required: spec.required, + Keywords: append([]string{}, spec.keywords...), + ActionLabel: "去补 FAQ", + } + if knowledgeBaseID > 0 { + item.ActionHref = fmt.Sprintf("/dashboard/knowledge?knowledgeBaseId=%d", knowledgeBaseID) + } else { + item.ActionHref = "/dashboard/knowledge" + } + if matched := matchDigitalStoreKnowledgeAssistantFAQ(spec, faqs); matched != nil { + item.Covered = true + item.MatchedFAQID = matched.ID + item.ActionHref = fmt.Sprintf("/dashboard/knowledge?knowledgeBaseId=%d&faqId=%d", matched.KnowledgeBaseID, matched.ID) + item.ActionLabel = "查看 FAQ" + } + items = append(items, item) + } + return items +} + +func matchDigitalStoreKnowledgeAssistantFAQ(spec digitalStoreKnowledgeAssistantSpec, faqs []models.KnowledgeFAQ) *models.KnowledgeFAQ { + for i := range faqs { + faq := &faqs[i] + text := strings.ToLower(strings.Join([]string{faq.Question, faq.Answer, faq.SimilarQuestions}, " ")) + if strings.TrimSpace(text) == "" { + continue + } + matched := 0 + for _, keyword := range spec.keywords { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword != "" && strings.Contains(text, keyword) { + matched++ + } + } + if matched >= minInt(2, len(spec.keywords)) { + return faq + } + } + return nil +} + +func digitalStoreKnowledgeAssistantSpecs(cfg digitalStoreProfileConfig) []digitalStoreKnowledgeAssistantSpec { + common := []digitalStoreKnowledgeAssistantSpec{ + buildKnowledgeAssistantSpec("store_basic", "门店地址、营业时间和联系方式是什么?", "客户最常先问门店在哪里、几点营业、怎么联系。", []string{"地址", "营业时间", "电话", "微信"}, true), + buildKnowledgeAssistantSpec("appointment", "如何预约到店、试用或咨询?", "高意向客户需要清楚预约流程和需要留下的信息。", []string{"预约", "到店", "手机号", "时间"}, true), + buildKnowledgeAssistantSpec("price_boundary", "价格、优惠、库存和最终成交价如何确认?", "避免 AI 编造最低价、库存和叠加优惠。", []string{"价格", "优惠", "库存", "顾问"}, true), + buildKnowledgeAssistantSpec("handoff", "哪些情况需要转人工或顾问跟进?", "把最终确认、投诉售后和高意向线索交给人工闭环。", []string{"人工", "顾问", "转人工", "跟进"}, true), + buildKnowledgeAssistantSpec("after_sales", "售后、退款、退货、投诉或赔付怎么处理?", "售后政策缺失会导致 AI 乱承诺退款退货或赔付。", []string{"售后", "退款", "退货", "投诉", "赔付"}, true), + } + switch normalizeDigitalStoreIndustryKey(cfg) { + case "medical": + return append(common, + buildKnowledgeAssistantSpec("medical_diagnosis_boundary", "线上口腔咨询和医生面诊的边界是什么?", "医疗行业必须说明线上咨询不能替代医生诊断。", []string{"线上咨询", "面诊", "医生", "诊断"}, true), + buildKnowledgeAssistantSpec("medical_emergency", "牙痛、出血、肿胀等急症应如何处理?", "急症风险需要尽快引导到院或人工。", []string{"牙痛", "出血", "肿胀", "尽快到院"}, true), + buildKnowledgeAssistantSpec("medical_fee", "治疗费用、周期、医保和退款如何确认?", "费用、周期和医保不可由 AI 直接承诺。", []string{"费用", "周期", "医保", "退款"}, true), + ) + case "education": + return append(common, + buildKnowledgeAssistantSpec("education_course", "课程适合哪些学生、班型和课时如何安排?", "教育行业需要明确年级、目标、班型和课时。", []string{"课程", "年级", "班型", "课时"}, true), + buildKnowledgeAssistantSpec("education_result_boundary", "提分、保过、录取和证书结果如何说明?", "教育行业不能承诺保过、提分幅度或录取结果。", []string{"提分", "保过", "录取", "证书"}, true), + buildKnowledgeAssistantSpec("education_refund", "试听、报名、退费和合同规则是什么?", "报名转化前需要清楚试听和退费边界。", []string{"试听", "报名", "退费", "合同"}, true), + ) + case "finance": + return append(common, + buildKnowledgeAssistantSpec("finance_risk", "收益、保本、利率、额度和风险如何说明?", "金融行业不能承诺收益、保本、贷款必批或固定额度。", []string{"收益", "保本", "利率", "额度", "风险"}, true), + buildKnowledgeAssistantSpec("finance_sensitive_info", "客户哪些敏感信息不能在聊天中收集?", "金融行业必须避免收集银行卡密码、验证码等高敏信息。", []string{"银行卡", "密码", "验证码", "身份证"}, true), + buildKnowledgeAssistantSpec("finance_handoff", "哪些金融咨询必须转持牌顾问或人工确认?", "合同、风险评级和具体方案应转人工。", []string{"持牌", "顾问", "合同", "风险评级"}, true), + ) + case "home_decoration": + return append(common, + buildKnowledgeAssistantSpec("decoration_measure", "量房、户型、面积和预算如何收集?", "家装咨询要先收集面积、户型和预算。", []string{"量房", "户型", "面积", "预算"}, true), + buildKnowledgeAssistantSpec("decoration_quote", "报价、工期、材料和增项如何确认?", "家装行业不能承诺一口价、绝不增项或固定工期。", []string{"报价", "工期", "材料", "增项"}, true), + buildKnowledgeAssistantSpec("decoration_after_sales", "施工延期、质量问题、退款赔付和验收怎么处理?", "施工争议必须转人工并依据合同处理。", []string{"施工", "延期", "质量", "验收", "赔付"}, true), + ) + default: + return append(common, + buildKnowledgeAssistantSpec("product_selection", "如何按预算、人群和场景推荐主推产品?", "通用导购需要把产品推荐口径写清楚。", []string{"预算", "人群", "场景", "推荐"}, true), + buildKnowledgeAssistantSpec("compliance_boundary", "哪些效果、资质、案例和结果不能承诺?", "高咨询行业都需要明确禁用承诺。", []string{"效果", "资质", "案例", "承诺"}, true), + ) + } +} + +func buildKnowledgeAssistantSpec(key string, question string, reason string, keywords []string, required bool) digitalStoreKnowledgeAssistantSpec { + return digitalStoreKnowledgeAssistantSpec{key: key, question: question, reason: reason, keywords: keywords, required: required} +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + +func buildDigitalStoreHealthCheck(key string, label string, status string, message string) response.DigitalStoreHealthCheckResponse { + item := response.DigitalStoreHealthCheckResponse{ + Key: key, + Label: label, + Status: status, + Message: valueOrDefault(message, "-"), + ActionHref: digitalStoreHealthCheckActionHref(key), + ActionLabel: digitalStoreHealthCheckActionLabel(key), + } + if status == "ok" { + item.ActionHref = "" + item.ActionLabel = "" + } + return item +} + +func digitalStoreHealthCheckActionHref(key string) string { + switch key { + case "llm", "embedding": + return "/dashboard/ai-configs" + case "product_index": + return "/dashboard/products" + case "promotion_index": + return "/dashboard/promotions" + case "vector_db": + return "/dashboard/store-setup" + default: + return "" + } +} + +func digitalStoreHealthCheckActionLabel(key string) string { + switch key { + case "llm", "embedding": + return "去配置模型" + case "product_index", "promotion_index": + return "去重建索引" + case "vector_db": + return "查看部署配置" + default: + return "去处理" + } +} + +func buildDigitalStoreHumanHandoff(agent *models.AIAgent) response.DigitalStoreHumanHandoffResponse { + if agent == nil { + return response.DigitalStoreHumanHandoffResponse{ + Message: "未生成数字店长 Agent。", + } + } + teamIDs := utils.SplitInt64s(agent.TeamIDs) + ret := response.DigitalStoreHumanHandoffResponse{ + AgentTeamIDs: teamIDs, + } + if len(teamIDs) == 0 { + ret.Message = "数字店长 Agent 尚未绑定人工顾问组。" + return ret + } + ret.AgentProfileTotal = AgentProfileService.Count(sqls.NewCnd(). + In("team_id", teamIDs). + Where("status <> ?", enums.StatusDeleted)) + ret.AutoAssignProfiles = AgentProfileService.Count(sqls.NewCnd(). + In("team_id", teamIDs). + Eq("status", enums.StatusOk). + Eq("auto_assign_enabled", true)) + candidates, report, err := ConversationDispatchService.pickDispatchCandidates(teamIDs, time.Now()) + ret.ActiveTeamIDs = report.ActiveScheduleTeams + ret.EligibleProfiles = report.EligibleProfiles + ret.CandidateProfiles = len(candidates) + if err != nil { + ret.Message = "人工接待配置检查失败:" + err.Error() + return ret + } + ret.Ready = len(candidates) > 0 + if ret.Ready { + ret.Message = fmt.Sprintf("已绑定 %d 个顾问组,当前 %d 个组在排班,%d 名顾问可自动接待。", len(teamIDs), len(ret.ActiveTeamIDs), len(candidates)) + return ret + } + switch report.Reason { + case "no_active_schedule_team": + ret.Message = fmt.Sprintf("已绑定 %d 个顾问组,但当前没有生效排班。", len(teamIDs)) + case "no_matched_profile": + ret.Message = "当前排班顾问组中没有客服档案。" + case "no_enabled_user", "no_profile_for_enabled_user": + ret.Message = "顾问档案对应的后台账号未启用。" + case "all_candidates_at_capacity": + ret.Message = "当前顾问已达到最大并发接待数。" + default: + ret.Message = "暂无可自动接待的人工顾问。" + } + return ret +} + +func buildDigitalStoreWebEntry(channel *models.Channel, publicBaseURL string) response.DigitalStoreWebEntryResponse { + if channel == nil { + return response.DigitalStoreWebEntryResponse{} + } + cfg, err := ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + cfg = &dto.WebChannelConfig{ + Title: "AI数字店长", + ThemeColor: "#2563eb", + Position: "right", + Width: "380px", + } + } + baseURL := strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + entry := response.DigitalStoreWebEntryResponse{ + ChannelID: channel.ID, + ChannelCode: strings.TrimSpace(channel.ChannelID), + ChannelName: strings.TrimSpace(channel.Name), + Title: strings.TrimSpace(cfg.Title), + Subtitle: strings.TrimSpace(cfg.Subtitle), + ThemeColor: strings.TrimSpace(cfg.ThemeColor), + Position: strings.TrimSpace(cfg.Position), + Width: strings.TrimSpace(cfg.Width), + } + if baseURL != "" && entry.ChannelCode != "" { + entry.ChatURL = baseURL + "/support/chat/?channelId=" + entry.ChannelCode + entry.EmbedSnippet = buildDigitalStoreEmbedSnippet(entry, baseURL) + } + return entry +} + +func buildDigitalStoreEmbedSnippet(entry response.DigitalStoreWebEntryResponse, baseURL string) string { + return fmt.Sprintf(` +`, + jsStringLiteral(entry.ChannelCode), + jsStringLiteral(baseURL), + jsStringLiteral(entry.Title), + jsStringLiteral(entry.Subtitle), + jsStringLiteral(entry.ThemeColor), + jsStringLiteral(entry.Position), + jsStringLiteral(entry.Width), + baseURL, + ) +} + +func jsStringLiteral(value string) string { + raw, err := json.Marshal(strings.TrimSpace(value)) + if err != nil { + return `""` + } + return string(raw) +} + +func formatDigitalStoreWebEntry(entry response.DigitalStoreWebEntryResponse) string { + parts := []string{} + if entry.Title != "" { + parts = append(parts, "标题:"+entry.Title) + } + if entry.Subtitle != "" { + parts = append(parts, "副标题:"+entry.Subtitle) + } + if entry.ThemeColor != "" { + parts = append(parts, "主题色:"+entry.ThemeColor) + } + if entry.Position != "" || entry.Width != "" { + parts = append(parts, "位置/宽度:"+valueOrDefault(entry.Position, "-")+" / "+valueOrDefault(entry.Width, "-")) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ";") +} + +func (s *digitalStoreProfileService) GetNotificationStatus() response.DigitalStoreNotificationStatusResponse { + cfg := s.loadConfig() + webhook, configReady := safeWebhookNotifyConfig() + ret := response.DigitalStoreNotificationStatusResponse{ + ProfileWebhookURLSet: strings.TrimSpace(cfg.EnterpriseWebhookURL) != "", + Format: valueOrDefault(webhook.Format, "generic"), + Configured: configReady && strings.TrimSpace(webhook.URL) != "", + HasSecret: configReady && strings.TrimSpace(webhook.Secret) != "", + } + ret.Enabled = configReady && webhook.Enabled && strings.TrimSpace(webhook.URL) != "" + switch { + case ret.Enabled: + ret.Status = "enabled" + ret.Message = "全局 notify.webhook 已启用,可接收高意向线索、预约线索、会话分配和转人工提醒。" + case !configReady: + ret.Status = "unavailable" + ret.Message = "后端配置尚未加载,无法读取 notify.webhook。" + case ret.ProfileWebhookURLSet: + ret.Status = "profile_only" + ret.Message = "店长资料中填写了 Webhook,但实际通知需启用全局 notify.webhook。" + case ret.Configured: + ret.Status = "disabled" + ret.Message = "notify.webhook 已配置 URL 但未启用。" + default: + ret.Status = "missing" + ret.Message = "未配置外部通知 Webhook。" + } + return ret +} + +func (s *digitalStoreProfileService) GetSecurityChecks(notificationStatus response.DigitalStoreNotificationStatusResponse) []response.DigitalStoreSecurityCheckResponse { + cfg, configReady := safeRuntimeConfig() + if !configReady { + return []response.DigitalStoreSecurityCheckResponse{ + buildSecurityCheck("config", "后端配置", "blocking", "后端配置尚未加载,无法确认上线安全项。"), + buildSecurityCheck("notification", "外部通知", "warning", notificationStatus.Message), + } + } + + checks := []response.DigitalStoreSecurityCheckResponse{} + customerSecret := strings.TrimSpace(cfg.CustomerSession.Secret) + switch { + case isBlankOrPlaceholder(customerSecret): + checks = append(checks, buildSecurityCheck("customer_session_secret", "客户聊天密钥", "blocking", "customerSession.secret 未配置,客户聊天会话签名不安全。")) + case len(customerSecret) < 32: + checks = append(checks, buildSecurityCheck("customer_session_secret", "客户聊天密钥", "warning", "customerSession.secret 已配置,但建议使用至少 32 位随机字符串。")) + default: + checks = append(checks, buildSecurityCheck("customer_session_secret", "客户聊天密钥", "ok", "customerSession.secret 已配置。")) + } + + bootstrapPassword := strings.TrimSpace(os.Getenv(constants.EnvBootstrapAdminPassword)) + switch { + case isBlankOrPlaceholder(bootstrapPassword): + checks = append(checks, buildSecurityCheck("bootstrap_admin_password", "首次管理员密码", "blocking", "未设置 AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD;首次初始化可能使用默认 admin 密码。")) + case bootstrapPassword == constants.BootstrapAdminPassword: + checks = append(checks, buildSecurityCheck("bootstrap_admin_password", "首次管理员密码", "blocking", "AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD 仍是默认值,请改为商家独立强密码。")) + case len(bootstrapPassword) < 12: + checks = append(checks, buildSecurityCheck("bootstrap_admin_password", "首次管理员密码", "warning", "首次管理员密码已设置,但长度偏短,建议至少 12 位。")) + default: + checks = append(checks, buildSecurityCheck("bootstrap_admin_password", "首次管理员密码", "ok", "首次管理员密码环境变量已设置。")) + } + + if cfg.Auth.MaxFailedAttempts <= 0 { + checks = append(checks, buildSecurityCheck("auth_lockout", "登录失败锁定", "warning", "auth.maxFailedAttempts 未启用,建议开启后台登录失败临时锁定。")) + } else { + checks = append(checks, buildSecurityCheck("auth_lockout", "登录失败锁定", "ok", fmt.Sprintf("已启用后台登录失败锁定,阈值为 %d 次。", cfg.Auth.MaxFailedAttempts))) + } + + origins := normalizedCORSOrigins(cfg.Server.CORS.AllowedOrigins) + switch { + case len(origins) == 0: + checks = append(checks, buildSecurityCheck("cors_allowed_origins", "CORS 白名单", "warning", "未配置跨域白名单;同源可用,官网嵌入聊天前需加入商家域名。")) + case hasWildcardOrigin(origins): + checks = append(checks, buildSecurityCheck("cors_allowed_origins", "CORS 白名单", "blocking", "CORS 白名单不应使用 *,请改为后台域名和商家官网域名。")) + case allLocalOrigins(origins): + checks = append(checks, buildSecurityCheck("cors_allowed_origins", "CORS 白名单", "warning", "CORS 仍只包含 localhost/127.0.0.1,上线前请改为正式域名。")) + default: + checks = append(checks, buildSecurityCheck("cors_allowed_origins", "CORS 白名单", "ok", fmt.Sprintf("已配置 %d 个跨域白名单域名。", len(origins)))) + } + + dbType := strings.ToLower(strings.TrimSpace(cfg.DB.Type)) + switch dbType { + case "mysql": + checks = append(checks, buildSecurityCheck("database", "数据库", "ok", "数据库类型为 MySQL,适合正式单商家部署。")) + case "sqlite": + checks = append(checks, buildSecurityCheck("database", "数据库", "warning", "当前使用 SQLite,小型单店可用;正式高并发或多人后台建议改 MySQL。")) + case "": + checks = append(checks, buildSecurityCheck("database", "数据库", "warning", "数据库类型未配置,请确认运行环境使用独立商家数据库。")) + default: + checks = append(checks, buildSecurityCheck("database", "数据库", "warning", "数据库类型为 "+dbType+",请确认生产环境已验证。")) + } + + vectorType := strings.ToLower(strings.TrimSpace(cfg.VectorDB.Type)) + if vectorType == "qdrant" || vectorType == "lancedb" { + checks = append(checks, buildSecurityCheck("vector_db", "向量库", "ok", "向量库类型为 "+vectorType+"。")) + } else { + checks = append(checks, buildSecurityCheck("vector_db", "向量库", "blocking", "vectorDB.type 未配置为 qdrant 或 lancedb,知识检索可能不可用。")) + } + + switch { + case notificationStatus.Enabled: + checks = append(checks, buildSecurityCheck("notification", "外部通知", "ok", notificationStatus.Message)) + case notificationStatus.Configured || notificationStatus.ProfileWebhookURLSet: + checks = append(checks, buildSecurityCheck("notification", "外部通知", "warning", notificationStatus.Message)) + default: + checks = append(checks, buildSecurityCheck("notification", "外部通知", "warning", "未启用外部通知;高意向线索和转人工提醒只能依赖站内查看。")) + } + switch { + case notificationStatus.Enabled && notificationStatus.HasSecret: + checks = append(checks, buildSecurityCheck("webhook_secret", "Webhook 签名密钥", "ok", "外部通知已配置签名密钥。")) + case notificationStatus.Enabled: + checks = append(checks, buildSecurityCheck("webhook_secret", "Webhook 签名密钥", "warning", "外部通知已启用但未配置 notify.webhook.secret,商家自建接收端无法校验消息签名。")) + case notificationStatus.Configured: + checks = append(checks, buildSecurityCheck("webhook_secret", "Webhook 签名密钥", "warning", "notify.webhook 已配置 URL,启用前建议补充签名密钥。")) + default: + checks = append(checks, buildSecurityCheck("webhook_secret", "Webhook 签名密钥", "warning", "未启用外部通知;启用商家通知接口时建议配置签名密钥。")) + } + return checks +} + +func (s *digitalStoreProfileService) TestWebhookNotify(operator *dto.AuthPrincipal) (response.DigitalStoreWebhookTestResponse, error) { + if operator == nil { + return response.DigitalStoreWebhookTestResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + status := s.GetNotificationStatus() + ret := response.DigitalStoreWebhookTestResponse{ + DigitalStoreNotificationStatusResponse: status, + TestedAt: utils.FormatTime(time.Now()), + } + if !status.Enabled { + return ret, nil + } + cfg := s.loadConfig() + body := strings.Join([]string{ + "这是一条 AI 数字店长外部通知测试。", + "品牌:" + valueOrDefault(cfg.BrandName, "未配置"), + "门店:" + valueOrDefault(cfg.StoreName, "未配置"), + "操作人:" + valueOrDefault(operator.Username, "system"), + "时间:" + ret.TestedAt, + }, "\n") + if err := WebhookNotifyService.SendText("digital_store_webhook_test", "AI数字店长外部通知测试", body, map[string]any{ + "brandName": cfg.BrandName, + "storeName": cfg.StoreName, + "operatorId": operator.UserID, + }); err != nil { + ret.FailedTotal = 1 + ret.Message = "测试通知发送失败:" + err.Error() + return ret, nil + } + ret.Sent = true + ret.SentTotal = 1 + ret.Message = "测试通知已发送,请在商家通知群或接收系统中确认。" + return ret, nil +} + +func (s *digitalStoreProfileService) TestWebhookNotifyScenarios(operator *dto.AuthPrincipal) (response.DigitalStoreWebhookTestResponse, error) { + if operator == nil { + return response.DigitalStoreWebhookTestResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + status := s.GetNotificationStatus() + ret := response.DigitalStoreWebhookTestResponse{ + DigitalStoreNotificationStatusResponse: status, + TestedAt: utils.FormatTime(time.Now()), + } + scenarios := s.buildWebhookNotifyTestScenarios(operator, ret.TestedAt) + ret.Scenarios = make([]response.DigitalStoreWebhookTestScenarioResponse, 0, len(scenarios)) + if !status.Enabled { + ret.Message = status.Message + for _, scenario := range scenarios { + ret.Scenarios = append(ret.Scenarios, response.DigitalStoreWebhookTestScenarioResponse{ + Key: scenario.Key, + EventType: scenario.EventType, + Title: scenario.Title, + Message: status.Message, + }) + } + return ret, nil + } + for _, scenario := range scenarios { + item := response.DigitalStoreWebhookTestScenarioResponse{ + Key: scenario.Key, + EventType: scenario.EventType, + Title: scenario.Title, + } + if err := WebhookNotifyService.SendText(scenario.EventType, scenario.Title, scenario.Body, scenario.Metadata); err != nil { + item.Message = err.Error() + ret.FailedTotal++ + ret.Scenarios = append(ret.Scenarios, item) + continue + } + item.Sent = true + item.Message = "已发送" + ret.SentTotal++ + ret.Scenarios = append(ret.Scenarios, item) + } + if ret.FailedTotal > 0 { + ret.Message = fmt.Sprintf("关键通知测试未全部发送:成功 %d,失败 %d。请检查 Webhook 地址、格式、签名密钥或接收端日志。", ret.SentTotal, ret.FailedTotal) + return ret, nil + } + ret.Sent = true + ret.Message = fmt.Sprintf("已发送 %d 类关键事件测试,请在商家通知群或接收系统中确认。", ret.SentTotal) + return ret, nil +} + +type digitalStoreWebhookNotifyTestScenario struct { + Key string + EventType string + Title string + Body string + Metadata map[string]any +} + +func (s *digitalStoreProfileService) buildWebhookNotifyTestScenarios(operator *dto.AuthPrincipal, testedAt string) []digitalStoreWebhookNotifyTestScenario { + cfg := s.loadConfig() + brandName := valueOrDefault(cfg.BrandName, "AI数字店长样板") + storeName := valueOrDefault(cfg.StoreName, "样板门店") + operatorName := "system" + operatorID := int64(0) + if operator != nil { + operatorName = valueOrDefault(operator.Username, "system") + operatorID = operator.UserID + } + baseMetadata := map[string]any{ + "brandName": brandName, + "storeName": storeName, + "operatorId": operatorID, + "test": true, + } + withMetadata := func(scenario string, extra map[string]any) map[string]any { + ret := map[string]any{} + for key, value := range baseMetadata { + ret[key] = value + } + ret["scenario"] = scenario + for key, value := range extra { + ret[key] = value + } + return ret + } + body := func(lines ...string) string { + values := []string{ + "这是 AI 数字店长关键事件外部通知测试。", + "品牌:" + brandName, + "门店:" + storeName, + "操作人:" + operatorName, + "时间:" + testedAt, + } + values = append(values, lines...) + return strings.Join(values, "\n") + } + return []digitalStoreWebhookNotifyTestScenario{ + { + Key: "high_intent_lead", + EventType: "sales_lead_created", + Title: "高意向销售线索提醒", + Body: body( + "客户:王女士", + "联系方式:13800000000 / 微信 wx_muse_test", + "意向:预算 15000 元,关注脊护支撑款,准备到店试躺。", + ), + Metadata: withMetadata("high_intent_lead", map[string]any{"intentLevel": "high", "actionUrl": "/dashboard/sales-leads"}), + }, + { + Key: "appointment_lead", + EventType: "sales_lead_created", + Title: "预约到店线索提醒", + Body: body( + "客户:李先生", + "预约:本周六下午,两人到店试躺。", + "提醒:请顾问确认到店时间、门店和体验产品。", + ), + Metadata: withMetadata("appointment_lead", map[string]any{"buyingStage": "appointment", "actionUrl": "/dashboard/sales-leads?taskView=appointment"}), + }, + { + Key: "human_handoff", + EventType: "conversation_assigned", + Title: "客户转人工提醒", + Body: body( + "会话:#10001", + "客户诉求:想确认最终成交价和库存。", + "摘要:AI 已完成产品推荐,客户需要真人顾问跟进。", + ), + Metadata: withMetadata("human_handoff", map[string]any{"conversationId": 10001, "actionUrl": "/dashboard/conversations"}), + }, + { + Key: "unassigned_lead", + EventType: "sales_lead_follow_up_reminder", + Title: "未分配线索跟进提醒", + Body: body( + "待处理:3 条未分配高意向或预约线索。", + "建议:门店店长先分派顾问,再安排今日跟进。", + ), + Metadata: withMetadata("unassigned_lead", map[string]any{"unassignedTotal": 3, "actionUrl": "/dashboard/sales-leads?ownerUserId=0"}), + }, + { + Key: "after_sales_risk", + EventType: "sales_lead_created", + Title: "售后风险线索提醒", + Body: body( + "客户:赵女士", + "问题:已购床垫出现异响,客户表达投诉风险。", + "建议:优先人工安抚并创建售后工单。", + ), + Metadata: withMetadata("after_sales_risk", map[string]any{"buyingStage": "after_sales", "risk": true, "actionUrl": "/dashboard/sales-leads?taskView=after_sales"}), + }, + } +} + +func (s *digitalStoreProfileService) CleanupDemoData(operator *dto.AuthPrincipal) (response.DigitalStoreDemoDataCleanupResponse, error) { + if operator == nil { + return response.DigitalStoreDemoDataCleanupResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + deleted := map[string]int64{} + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + cleanupItems := []struct { + key string + model any + }{ + {key: "leadFollowUps", model: &models.LeadFollowUp{}}, + {key: "salesLeads", model: &models.SalesLead{}}, + {key: "ticketProgress", model: &models.TicketProgress{}}, + {key: "ticketTags", model: &models.TicketTag{}}, + {key: "tickets", model: &models.Ticket{}}, + {key: "notifications", model: &models.Notification{}}, + {key: "conversationInterrupts", model: &models.ConversationInterrupt{}}, + {key: "channelMessageOutbox", model: &models.ChannelMessageOutbox{}}, + {key: "wxworkKFMessageRefs", model: &models.WxWorkKFMessageRef{}}, + {key: "wxworkKFConversations", model: &models.WxWorkKFConversation{}}, + {key: "conversationAssignments", model: &models.ConversationAssignment{}}, + {key: "conversationTags", model: &models.ConversationTag{}}, + {key: "conversationEventLogs", model: &models.ConversationEventLog{}}, + {key: "conversationReadStates", model: &models.ConversationReadState{}}, + {key: "conversationParticipants", model: &models.ConversationParticipant{}}, + {key: "messages", model: &models.Message{}}, + {key: "conversations", model: &models.Conversation{}}, + {key: "knowledgeFeedback", model: &models.KnowledgeFeedback{}}, + {key: "knowledgeRetrieveHits", model: &models.KnowledgeRetrieveHit{}}, + {key: "knowledgeRetrieveLogs", model: &models.KnowledgeRetrieveLog{}}, + {key: "aiWorkflowNodeRuns", model: &models.AIWorkflowNodeRun{}}, + {key: "aiWorkflowRuns", model: &models.AIWorkflowRun{}}, + {key: "skillRunLogs", model: &models.SkillRunLog{}}, + } + for _, item := range cleanupItems { + if err := deleteDigitalStoreDemoData(ctx.Tx, deleted, item.key, item.model); err != nil { + return err + } + } + return nil + }); err != nil { + return response.DigitalStoreDemoDataCleanupResponse{}, err + } + total := int64(0) + for _, count := range deleted { + total += count + } + return response.DigitalStoreDemoDataCleanupResponse{ + CleanedAt: utils.FormatTime(time.Now()), + Message: fmt.Sprintf("已清理 %d 条演示运营数据;产品、活动、知识库、模型、Agent、渠道、客户档案和交付记录已保留。", total), + Deleted: deleted, + }, nil +} + +func deleteDigitalStoreDemoData(db *gorm.DB, deleted map[string]int64, key string, model any) error { + result := db.Where("1 = 1").Delete(model) + if result.Error != nil { + return result.Error + } + deleted[key] = result.RowsAffected + return nil +} + +func (s *digitalStoreProfileService) GetLatestDeliveryRecord() *response.DigitalStoreDeliveryRecordResponse { + item := repositories.DigitalStoreDeliveryRecordRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("status <> ?", enums.StatusDeleted). + Desc("id")) + return buildDigitalStoreDeliveryRecordResponse(item) +} + +func (s *digitalStoreProfileService) CreateDeliveryRecord(req request.DigitalStoreDeliveryRecordCreateRequest, operator *dto.AuthPrincipal) (*response.DigitalStoreDeliveryRecordResponse, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + report := s.GetDeliveryReport(req.PublicBaseURL) + status := s.GetSetupStatus() + reportJSON, err := json.Marshal(report) + if err != nil { + return nil, err + } + acceptanceStatus := normalizeDigitalStoreAcceptanceStatus(req.AcceptanceStatus, report.Ready) + item := &models.DigitalStoreDeliveryRecord{ + BrandName: report.BrandName, + StoreName: report.StoreName, + Ready: report.Ready, + AcceptanceStatus: acceptanceStatus, + AcceptanceSummary: strings.TrimSpace(req.AcceptanceSummary), + AcceptanceCommand: report.AcceptanceCommand, + DashboardURL: report.DashboardURL, + ChatURL: report.ChatURL, + WebChannelCode: status.WebChannelCode, + ReportMarkdown: report.Markdown, + ReportJSON: string(reportJSON), + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(operator), + } + if item.AcceptanceSummary == "" { + item.AcceptanceSummary = defaultDigitalStoreAcceptanceSummary(acceptanceStatus, report.Ready) + } + if err := repositories.DigitalStoreDeliveryRecordRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return buildDigitalStoreDeliveryRecordResponse(item), nil +} + +func (s *digitalStoreProfileService) CreateAcceptanceResultRecord(req request.DigitalStoreAcceptanceResultCreateRequest, operator *dto.AuthPrincipal) (*response.DigitalStoreDeliveryRecordResponse, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + report := s.GetDeliveryReport(req.PublicBaseURL) + status := s.GetSetupStatus() + reportJSON, err := json.Marshal(report) + if err != nil { + return nil, err + } + resultJSON, err := json.Marshal(req.Results) + if err != nil { + return nil, err + } + scenarioTotal := req.ScenarioTotal + if scenarioTotal <= 0 { + scenarioTotal = len(req.Results) + } + passedTotal := req.PassedTotal + if passedTotal < 0 { + passedTotal = 0 + } + failedTotal := req.FailedTotal + if failedTotal < 0 { + failedTotal = 0 + } + acceptanceStatus := "passed" + if failedTotal > 0 || (scenarioTotal > 0 && passedTotal < scenarioTotal) { + acceptanceStatus = "failed" + } + startedAt := parseDigitalStoreAcceptanceTime(req.StartedAt) + finishedAt := parseDigitalStoreAcceptanceTime(req.FinishedAt) + item := &models.DigitalStoreDeliveryRecord{ + BrandName: report.BrandName, + StoreName: report.StoreName, + Ready: report.Ready, + AcceptanceStatus: acceptanceStatus, + AcceptanceSummary: fmt.Sprintf("自动化冒烟验收:%d/%d 通过,%d 项失败。", passedTotal, scenarioTotal, failedTotal), + AcceptanceCommand: valueOrDefault(req.Command, report.AcceptanceCommand), + ScenarioTotal: scenarioTotal, + PassedTotal: passedTotal, + FailedTotal: failedTotal, + AcceptanceStartedAt: startedAt, + AcceptanceFinishedAt: finishedAt, + AcceptanceResultJSON: string(resultJSON), + DashboardURL: report.DashboardURL, + ChatURL: report.ChatURL, + WebChannelCode: status.WebChannelCode, + ReportMarkdown: report.Markdown, + ReportJSON: string(reportJSON), + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.DigitalStoreDeliveryRecordRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return buildDigitalStoreDeliveryRecordResponse(item), nil +} + +func parseDigitalStoreAcceptanceTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + layouts := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} + for _, layout := range layouts { + parsed, err := time.Parse(layout, value) + if err == nil { + return &parsed + } + } + return nil +} + +func (s *digitalStoreProfileService) EnsureRuntime(operator *dto.AuthPrincipal) (response.DigitalStoreSetupStatusResponse, error) { + if operator == nil { + return response.DigitalStoreSetupStatusResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + cfg := s.loadConfig() + if !cfg.Initialized { + return response.DigitalStoreSetupStatusResponse{}, errorsx.InvalidParam("digital store profile is not initialized") + } + aiConfig := s.findActiveLLMConfig() + if aiConfig == nil { + return response.DigitalStoreSetupStatusResponse{}, errorsx.InvalidParam("enable an LLM model config before generating digital store runtime") + } + kbID, err := s.ensureKnowledgeBase(operator) + if err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + if cfg.KnowledgeBaseID != kbID { + cfg.KnowledgeBaseID = kbID + if err := s.saveConfig(cfg, operator); err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + } + if err := s.SyncKnowledgeFAQ(); err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + cfg = s.loadConfig() + defaultTeamID, err := s.ensureDefaultHumanHandoffRuntime(operator) + if err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + agent, err := s.ensureAgent(cfg, aiConfig.ID, defaultTeamID, operator) + if err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + if err := s.ensureAgentWorkflowPublished(agent.ID, operator); err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + agent = AIAgentService.Get(agent.ID) + if err := s.ensureWebChannel(cfg, agent, operator); err != nil { + return response.DigitalStoreSetupStatusResponse{}, err + } + return s.GetSetupStatus(), nil +} + +func (s *digitalStoreProfileService) UpdateProfile(req request.DigitalStoreProfileRequest, operator *dto.AuthPrincipal) (response.DigitalStoreProfileResponse, error) { + if operator == nil { + return response.DigitalStoreProfileResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + cfg, err := s.buildConfig(req) + if err != nil { + return response.DigitalStoreProfileResponse{}, err + } + if err := s.saveConfig(cfg, operator); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + if err := s.SyncKnowledgeFAQ(); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + return s.GetProfile(), nil +} + +func (s *digitalStoreProfileService) SeedMuseProfile(operator *dto.AuthPrincipal) (response.DigitalStoreProfileResponse, error) { + return s.ApplyTemplate("muse_bedding", operator) +} + +func (s *digitalStoreProfileService) ApplyTemplate(templateCode string, operator *dto.AuthPrincipal) (response.DigitalStoreProfileResponse, error) { + if operator == nil { + return response.DigitalStoreProfileResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + bundle, err := s.buildBuiltinTemplateBundle(templateCode) + if err != nil { + return response.DigitalStoreProfileResponse{}, err + } + return s.applyTemplateBundle(bundle, operator) +} + +func (s *digitalStoreProfileService) ApplyImportedTemplate(req request.DigitalStoreTemplateImportRequest, operator *dto.AuthPrincipal) (response.DigitalStoreProfileResponse, error) { + if operator == nil { + return response.DigitalStoreProfileResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + bundle, err := s.buildImportedTemplateBundle(req) + if err != nil { + return response.DigitalStoreProfileResponse{}, err + } + return s.applyTemplateBundle(bundle, operator) +} + +func (s *digitalStoreProfileService) applyTemplateBundle(bundle digitalStoreTemplateBundle, operator *dto.AuthPrincipal) (response.DigitalStoreProfileResponse, error) { + cfg := bundle.cfg + cfg.TemplateCode = bundle.template.Code + cfg.TemplateVersion = bundle.template.Version + cfg.TemplateAppliedAt = utils.FormatTime(time.Now()) + current := s.loadConfig() + if current.KnowledgeBaseID > 0 { + cfg.KnowledgeBaseID = current.KnowledgeBaseID + } + if current.KnowledgeFAQID > 0 { + cfg.KnowledgeFAQID = current.KnowledgeFAQID + } + if strings.TrimSpace(current.EnterpriseWebhookURL) != "" { + cfg.EnterpriseWebhookURL = current.EnterpriseWebhookURL + } + if cfg.KnowledgeBaseID == 0 { + kbID, err := s.ensureKnowledgeBase(operator) + if err != nil { + return response.DigitalStoreProfileResponse{}, err + } + cfg.KnowledgeBaseID = kbID + } + if err := s.saveConfig(cfg, operator); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + if err := ProductService.UpsertTemplateProducts(bundle.products, operator); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + if err := PromotionService.UpsertTemplatePromotions(bundle.promotions, operator); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + if err := s.SyncKnowledgeFAQ(); err != nil { + return response.DigitalStoreProfileResponse{}, err + } + return s.GetProfile(), nil +} + +func (s *digitalStoreProfileService) SyncKnowledgeFAQ() error { + cfg := s.loadConfig() + kbID, err := resolveDigitalStoreKnowledgeBaseID(cfg.KnowledgeBaseID) + if err != nil { + return err + } + cfg.KnowledgeBaseID = kbID + question, answer, similarQuestions := BuildDigitalStoreProfileFAQContent(cfg) + similarJSON, err := json.Marshal(similarQuestions) + if err != nil { + return err + } + now := time.Now() + faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), cfg.KnowledgeFAQID) + if faq == nil && cfg.KnowledgeFAQID > 0 { + cfg.KnowledgeFAQID = 0 + } + if faq == nil { + faq = &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + Question: question, + Answer: answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: enums.StatusOk, + Remark: "digital-store-profile", + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "system", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "system", + }, + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), faq); err != nil { + return err + } + cfg.KnowledgeFAQID = faq.ID + } else { + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faq.ID, map[string]any{ + "knowledge_base_id": kbID, + "question": question, + "answer": answer, + "similar_questions": string(similarJSON), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "status": enums.StatusOk, + "remark": "digital-store-profile", + "updated_at": now, + "update_user_id": 0, + "update_user_name": "system", + }); err != nil { + return err + } + } + if err := s.saveConfigWithSystemAudit(cfg); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), cfg.KnowledgeFAQID) +} + +func (s *digitalStoreProfileService) loadConfig() digitalStoreProfileConfig { + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", digitalStoreProfileConfigKey) + if item == nil || strings.TrimSpace(item.ConfigValue) == "" { + return digitalStoreProfileConfig{} + } + cfg := digitalStoreProfileConfig{} + if err := json.Unmarshal([]byte(item.ConfigValue), &cfg); err != nil { + return digitalStoreProfileConfig{} + } + return cfg +} + +func (s *digitalStoreProfileService) buildConfig(req request.DigitalStoreProfileRequest) (digitalStoreProfileConfig, error) { + brandName := strings.TrimSpace(req.BrandName) + storeName := strings.TrimSpace(req.StoreName) + if brandName == "" { + return digitalStoreProfileConfig{}, errorsx.InvalidParam("brand name is required") + } + if storeName == "" { + return digitalStoreProfileConfig{}, errorsx.InvalidParam("store name is required") + } + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: brandName, + Industry: strings.TrimSpace(req.Industry), + StoreName: storeName, + StoreAddress: strings.TrimSpace(req.StoreAddress), + BusinessHours: strings.TrimSpace(req.BusinessHours), + ContactPhone: strings.TrimSpace(req.ContactPhone), + ServiceWeChat: strings.TrimSpace(req.ServiceWeChat), + EnterpriseWebhookURL: strings.TrimSpace(req.EnterpriseWebhookURL), + AIManagerName: strings.TrimSpace(req.AIManagerName), + AIPersona: strings.TrimSpace(req.AIPersona), + ReplyStyle: strings.TrimSpace(req.ReplyStyle), + ForbiddenClaims: strings.TrimSpace(req.ForbiddenClaims), + HandoffPolicy: strings.TrimSpace(req.HandoffPolicy), + AppointmentPolicy: strings.TrimSpace(req.AppointmentPolicy), + KnowledgeBaseID: req.KnowledgeBaseID, + Initialized: true, + }, + } + if cfg.AIManagerName == "" { + cfg.AIManagerName = brandName + "数字店长" + } + if cfg.KnowledgeBaseID > 0 { + if _, err := resolveDigitalStoreKnowledgeBaseID(cfg.KnowledgeBaseID); err != nil { + return digitalStoreProfileConfig{}, err + } + } + if current := s.loadConfig(); current.Initialized || current.KnowledgeFAQID > 0 { + cfg.KnowledgeFAQID = current.KnowledgeFAQID + cfg.TemplateCode = current.TemplateCode + cfg.TemplateVersion = current.TemplateVersion + cfg.TemplateAppliedAt = current.TemplateAppliedAt + } + return cfg, nil +} + +func (s *digitalStoreProfileService) saveConfig(cfg digitalStoreProfileConfig, operator *dto.AuthPrincipal) error { + value, err := json.Marshal(cfg) + if err != nil { + return err + } + now := time.Now() + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", digitalStoreProfileConfigKey) + if item == nil { + return repositories.SystemConfigRepository.Create(sqls.DB(), &models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(value), + GroupCode: digitalStoreConfigGroup, + Title: "AI数字店长配置", + Description: "单商家部署下的品牌、门店、人设、预约和转人工规则", + Status: enums.StatusOk, + AuditFields: utils.BuildAuditFields(operator), + }) + } + return repositories.SystemConfigRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "config_value": string(value), + "group_code": digitalStoreConfigGroup, + "title": "AI数字店长配置", + "description": "单商家部署下的品牌、门店、人设、预约和转人工规则", + "status": enums.StatusOk, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} + +func (s *digitalStoreProfileService) saveConfigWithSystemAudit(cfg digitalStoreProfileConfig) error { + value, err := json.Marshal(cfg) + if err != nil { + return err + } + now := time.Now() + item := repositories.SystemConfigRepository.Take(sqls.DB(), "config_key = ?", digitalStoreProfileConfigKey) + if item == nil { + return repositories.SystemConfigRepository.Create(sqls.DB(), &models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(value), + GroupCode: digitalStoreConfigGroup, + Title: "AI数字店长配置", + Description: "单商家部署下的品牌、门店、人设、预约和转人工规则", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "system", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "system", + }, + }) + } + return repositories.SystemConfigRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "config_value": string(value), + "group_code": digitalStoreConfigGroup, + "title": "AI数字店长配置", + "description": "单商家部署下的品牌、门店、人设、预约和转人工规则", + "status": enums.StatusOk, + "updated_at": now, + "update_user_id": 0, + "update_user_name": "system", + }) +} + +func resolveDigitalStoreKnowledgeBaseID(id int64) (int64, error) { + if id > 0 { + kb := repositories.KnowledgeBaseRepository.Get(sqls.DB(), id) + if kb == nil || kb.Status == enums.StatusDeleted || kb.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) { + return 0, errorsx.InvalidParam("usable FAQ knowledge base not found") + } + return id, nil + } + kb := repositories.KnowledgeBaseRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_type", string(enums.KnowledgeBaseTypeFAQ)).Where("status <> ?", enums.StatusDeleted).Asc("id")) + if kb == nil { + return 0, errorsx.InvalidParam("create a FAQ knowledge base before syncing digital store profile") + } + return kb.ID, nil +} + +func (s *digitalStoreProfileService) ensureKnowledgeBase(operator *dto.AuthPrincipal) (int64, error) { + cfg := s.loadConfig() + if cfg.KnowledgeBaseID > 0 { + return resolveDigitalStoreKnowledgeBaseID(cfg.KnowledgeBaseID) + } + if kb := repositories.KnowledgeBaseRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("knowledge_type", string(enums.KnowledgeBaseTypeFAQ)). + Where("status <> ?", enums.StatusDeleted). + Asc("id")); kb != nil { + return kb.ID, nil + } + item, err := KnowledgeBaseService.CreateKnowledgeBase(request.CreateKnowledgeBaseRequest{ + Name: "数字店长 FAQ 知识库", + Description: "数字店长品牌、产品、活动和门店规则知识库", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + DefaultTopK: 10, + DefaultScoreThreshold: 0.35, + DefaultRerankLimit: 5, + AnswerMode: 2, + Remark: digitalStoreRuntimeSeedRemark, + }, operator) + if err != nil { + return 0, err + } + return item.ID, nil +} + +func (s *digitalStoreProfileService) ensureDefaultHumanHandoffRuntime(operator *dto.AuthPrincipal) (int64, error) { + team, err := s.ensureDefaultAgentTeam(operator) + if err != nil { + return 0, err + } + if team == nil || team.ID <= 0 { + return 0, nil + } + userID := resolveDigitalStoreDefaultConsultantUserID(operator) + if userID > 0 { + if err := s.ensureDefaultAgentProfile(team.ID, userID, operator); err != nil { + return 0, err + } + } + if err := s.ensureDefaultAgentTeamSchedule(team.ID, operator); err != nil { + return 0, err + } + return team.ID, nil +} + +func (s *digitalStoreProfileService) ensureDefaultAgentTeam(operator *dto.AuthPrincipal) (*models.AgentTeam, error) { + team := repositories.AgentTeamRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("(remark = ? OR name = ?)", digitalStoreRuntimeSeedRemark, digitalStoreDefaultTeamName). + Where("status <> ?", enums.StatusDeleted). + Asc("id")) + if team == nil { + return AgentTeamService.CreateAgentTeam(request.CreateAgentTeamRequest{ + Name: digitalStoreDefaultTeamName, + LeaderUserID: resolveDigitalStoreDefaultConsultantUserID(operator), + Status: int(enums.StatusOk), + Description: "AI 数字店长默认人工接待顾问组", + Remark: digitalStoreRuntimeSeedRemark, + }, operator) + } + if team.Status != enums.StatusOk || strings.TrimSpace(team.Remark) == "" { + if err := repositories.AgentTeamRepository.Updates(sqls.DB(), team.ID, map[string]any{ + "status": enums.StatusOk, + "remark": valueOrDefault(team.Remark, digitalStoreRuntimeSeedRemark), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return nil, err + } + team = AgentTeamService.Get(team.ID) + } + return team, nil +} + +func (s *digitalStoreProfileService) ensureDefaultAgentProfile(teamID, userID int64, operator *dto.AuthPrincipal) error { + if teamID <= 0 || userID <= 0 || UserService.Get(userID) == nil { + return nil + } + displayName := digitalStoreDefaultConsultantDisplayName(userID) + profile := AgentProfileService.GetByUserID(userID) + if profile == nil { + agentCode := digitalStoreDefaultAgentCode + if exists := AgentProfileService.Take("agent_code = ?", agentCode); exists != nil { + agentCode = fmt.Sprintf("%s_%d", digitalStoreDefaultAgentCode, userID) + } + _, err := AgentProfileService.CreateAgentProfile(request.CreateAgentProfileRequest{ + UserID: userID, + TeamID: teamID, + AgentCode: agentCode, + DisplayName: displayName, + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 10, + PriorityLevel: 10, + AutoAssignEnabled: true, + ReceiveOfflineMessage: true, + Remark: digitalStoreRuntimeSeedRemark, + }, operator) + return err + } + return repositories.AgentProfileRepository.Updates(sqls.DB(), profile.ID, map[string]any{ + "team_id": teamID, + "display_name": valueOrDefault(profile.DisplayName, displayName), + "service_status": enums.ServiceStatusIdle, + "max_concurrent_count": maxPositiveInt(profile.MaxConcurrentCount, 10), + "priority_level": maxPositiveInt(profile.PriorityLevel, 10), + "auto_assign_enabled": true, + "receive_offline_message": true, + "status": enums.StatusOk, + "remark": valueOrDefault(profile.Remark, digitalStoreRuntimeSeedRemark), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} + +func (s *digitalStoreProfileService) ensureDefaultAgentTeamSchedule(teamID int64, operator *dto.AuthPrincipal) error { + if teamID <= 0 { + return nil + } + now := time.Now() + if existing := AgentTeamScheduleService.Find(sqls.NewCnd(). + Eq("team_id", teamID). + Eq("status", enums.StatusOk). + Lte("start_at", now). + Gt("end_at", now)); len(existing) > 0 { + return nil + } + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 0, 31).Add(-time.Second) + return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), &models.AgentTeamSchedule{ + TeamID: teamID, + StartAt: start, + EndAt: end, + Remark: digitalStoreRuntimeSeedRemark, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + }) +} + +func resolveDigitalStoreDefaultConsultantUserID(operator *dto.AuthPrincipal) int64 { + if operator != nil && operator.UserID > 0 { + if user := UserService.Get(operator.UserID); user != nil && user.Status == enums.StatusOk { + return operator.UserID + } + } + users := UserService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id")) + if len(users) == 0 { + return 0 + } + return users[0].ID +} + +func digitalStoreDefaultConsultantDisplayName(userID int64) string { + user := UserService.Get(userID) + if user == nil { + return "门店顾问" + } + if nickname := strings.TrimSpace(user.Nickname); nickname != "" { + return nickname + } + if username := strings.TrimSpace(user.Username); username != "" { + return username + } + return "门店顾问" +} + +func normalizeDigitalStoreAgentTeamIDs(existing string, defaultTeamID int64) []int64 { + ids := utils.SplitInt64s(existing) + if defaultTeamID <= 0 { + return ids + } + for _, id := range ids { + if id == defaultTeamID { + return ids + } + } + return append(ids, defaultTeamID) +} + +func maxPositiveInt(current int, fallback int) int { + if current > 0 { + return current + } + return fallback +} + +func (s *digitalStoreProfileService) findActiveLLMConfig() *models.AIConfig { + return repositories.AIConfigRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("model_type", enums.AIModelTypeLLM). + Eq("status", enums.StatusOk). + Asc("id")) +} + +func (s *digitalStoreProfileService) findActiveEmbeddingConfig() *models.AIConfig { + return repositories.AIConfigRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("model_type", enums.AIModelTypeEmbedding). + Eq("status", enums.StatusOk). + Asc("id")) +} + +func (s *digitalStoreProfileService) findDigitalStoreAgent(cfg digitalStoreProfileConfig) *models.AIAgent { + name := defaultDigitalStoreAgentName(cfg) + if name != "" { + if item := repositories.AIAgentRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("name", name). + Where("status <> ?", enums.StatusDeleted). + Asc("id")); item != nil { + return item + } + } + return nil +} + +func (s *digitalStoreProfileService) ensureAgent(cfg digitalStoreProfileConfig, aiConfigID int64, defaultTeamID int64, operator *dto.AuthPrincipal) (*models.AIAgent, error) { + name := defaultDigitalStoreAgentName(cfg) + desiredPrompt := defaultDigitalStoreAgentPrompt(cfg) + desiredWelcome := defaultDigitalStoreWelcomeMessage(cfg) + desiredFallback := defaultDigitalStoreFallbackMessage(cfg) + desiredTeamIDs := normalizeDigitalStoreAgentTeamIDs("", defaultTeamID) + agent := s.findDigitalStoreAgent(cfg) + if agent == nil { + if exists := repositories.AIAgentRepository.Take(sqls.DB(), "name = ?", name); exists != nil { + name = fmt.Sprintf("%s %s", name, time.Now().Format("20060102150405")) + } + return AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ + Name: name, + Description: "单商家 AI 数字店长默认接待 Agent", + AIConfigID: aiConfigID, + ServiceMode: enums.IMConversationServiceModeAIFirst, + SystemPrompt: desiredPrompt, + WelcomeMessage: desiredWelcome, + ReplyTimeoutSeconds: 180, + TeamIDs: desiredTeamIDs, + HandoffMode: enums.AIAgentHandoffModeWaitPool, + FallbackMode: enums.AIAgentFallbackModeNoAnswer, + FallbackMessage: desiredFallback, + KnowledgeIDs: []int64{cfg.KnowledgeBaseID}, + }, operator) + } + desiredTeamIDs = normalizeDigitalStoreAgentTeamIDs(agent.TeamIDs, defaultTeamID) + if agent.Status != enums.StatusOk || + agent.AIConfigID != aiConfigID || + agent.KnowledgeIDs != fmt.Sprint(cfg.KnowledgeBaseID) || + agent.TeamIDs != utils.JoinInt64s(desiredTeamIDs) || + strings.TrimSpace(agent.SystemPrompt) != desiredPrompt || + strings.TrimSpace(agent.WelcomeMessage) != desiredWelcome || + strings.TrimSpace(agent.FallbackMessage) != desiredFallback { + if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ + ID: agent.ID, + CreateAIAgentRequest: request.CreateAIAgentRequest{ + Name: agent.Name, + Description: valueOrDefault(agent.Description, "单商家 AI 数字店长默认接待 Agent"), + AIConfigID: aiConfigID, + ServiceMode: enums.IMConversationServiceModeAIFirst, + SystemPrompt: desiredPrompt, + WelcomeMessage: desiredWelcome, + ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, + TeamIDs: desiredTeamIDs, + HandoffMode: enums.AIAgentHandoffModeWaitPool, + FallbackMode: enums.AIAgentFallbackModeNoAnswer, + FallbackMessage: desiredFallback, + KnowledgeIDs: []int64{cfg.KnowledgeBaseID}, + }, + }, operator); err != nil { + return nil, err + } + if agent.Status != enums.StatusOk { + if err := repositories.AIAgentRepository.Updates(sqls.DB(), agent.ID, map[string]any{ + "status": enums.StatusOk, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return nil, err + } + } + } + return AIAgentService.Get(agent.ID), nil +} + +func (s *digitalStoreProfileService) ensureAgentWorkflowPublished(agentID int64, operator *dto.AuthPrincipal) error { + agent := AIAgentService.Get(agentID) + if agent == nil { + return errorsx.InvalidParamI18n("error.e0002") + } + if agent.WorkflowVersionID > 0 { + return nil + } + _, err := AIWorkflowService.PublishAgentWorkflow(request.PublishAIWorkflowRequest{ + AgentID: agent.ID, + Definition: AIWorkflowService.DefaultAgentWorkflowDefinition(), + }, operator) + return err +} + +func (s *digitalStoreProfileService) findWebChannel(cfg digitalStoreProfileConfig) *models.Channel { + channelName := defaultDigitalStoreWebChannelName(cfg) + return repositories.ChannelRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("channel_type", enums.ChannelTypeWeb). + Where("(name = ? OR remark = ?)", channelName, digitalStoreRuntimeSeedRemark). + Where("status <> ?", enums.StatusDeleted). + Asc("id")) +} + +func (s *digitalStoreProfileService) ensureWebChannel(cfg digitalStoreProfileConfig, agent *models.AIAgent, operator *dto.AuthPrincipal) error { + if agent == nil || agent.ID <= 0 { + return errorsx.InvalidParamI18n("error.e0002") + } + channelName := defaultDigitalStoreWebChannelName(cfg) + configJSON, err := defaultDigitalStoreWebChannelConfig(cfg) + if err != nil { + return err + } + channel := s.findWebChannel(cfg) + if channel == nil { + _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: channelName, + ConfigJSON: configJSON, + Status: int(enums.StatusOk), + Remark: digitalStoreRuntimeSeedRemark, + }, operator) + return err + } + configJSON = channel.ConfigJSON + if strings.TrimSpace(configJSON) == "" { + configJSON, err = defaultDigitalStoreWebChannelConfig(cfg) + if err != nil { + return err + } + } + return ChannelService.UpdateChannel(request.UpdateChannelRequest{ + ID: channel.ID, + CreateChannelRequest: request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: valueOrDefault(channel.Name, channelName), + ConfigJSON: configJSON, + Status: int(enums.StatusOk), + Remark: channel.Remark, + }, + }, operator) +} + +func buildDigitalStoreProfileResponse(cfg digitalStoreProfileConfig) response.DigitalStoreProfileResponse { + return response.DigitalStoreProfileResponse{ + BrandName: cfg.BrandName, + Industry: cfg.Industry, + StoreName: cfg.StoreName, + StoreAddress: cfg.StoreAddress, + BusinessHours: cfg.BusinessHours, + ContactPhone: cfg.ContactPhone, + ServiceWeChat: cfg.ServiceWeChat, + EnterpriseWebhookURL: cfg.EnterpriseWebhookURL, + AIManagerName: cfg.AIManagerName, + AIPersona: cfg.AIPersona, + ReplyStyle: cfg.ReplyStyle, + ForbiddenClaims: cfg.ForbiddenClaims, + HandoffPolicy: cfg.HandoffPolicy, + AppointmentPolicy: cfg.AppointmentPolicy, + KnowledgeBaseID: cfg.KnowledgeBaseID, + KnowledgeFAQID: cfg.KnowledgeFAQID, + TemplateCode: cfg.TemplateCode, + TemplateVersion: cfg.TemplateVersion, + TemplateAppliedAt: cfg.TemplateAppliedAt, + Initialized: cfg.Initialized, + } +} + +func buildDigitalStoreDeliveryRecordResponse(item *models.DigitalStoreDeliveryRecord) *response.DigitalStoreDeliveryRecordResponse { + if item == nil { + return nil + } + ret := &response.DigitalStoreDeliveryRecordResponse{ + ID: item.ID, + BrandName: item.BrandName, + StoreName: item.StoreName, + Ready: item.Ready, + AcceptanceStatus: item.AcceptanceStatus, + AcceptanceSummary: item.AcceptanceSummary, + AcceptanceCommand: item.AcceptanceCommand, + ScenarioTotal: item.ScenarioTotal, + PassedTotal: item.PassedTotal, + FailedTotal: item.FailedTotal, + AcceptanceStartedAt: func() string { + if item.AcceptanceStartedAt == nil { + return "" + } + return utils.FormatTime(*item.AcceptanceStartedAt) + }(), + AcceptanceFinishedAt: func() string { + if item.AcceptanceFinishedAt == nil { + return "" + } + return utils.FormatTime(*item.AcceptanceFinishedAt) + }(), + DashboardURL: item.DashboardURL, + ChatURL: item.ChatURL, + WebChannelCode: item.WebChannelCode, + CreatedAt: utils.FormatTime(item.CreatedAt), + CreateUserName: item.CreateUserName, + } + if strings.TrimSpace(item.AcceptanceResultJSON) != "" { + var results []response.DigitalStoreAcceptanceScenarioResultResponse + if err := json.Unmarshal([]byte(item.AcceptanceResultJSON), &results); err == nil { + ret.AcceptanceResults = results + } + } + return ret +} + +func buildDigitalStoreMissingSteps(status response.DigitalStoreSetupStatusResponse) []string { + missing := make([]string, 0, 10) + if !status.ProfileInitialized { + missing = append(missing, "配置品牌与数字店长人设") + } + if status.ProductTotal == 0 { + missing = append(missing, "导入产品库") + } + if status.PromotionTotal == 0 { + missing = append(missing, "导入活动库") + } + if status.ProductTotal > 0 && (status.ProductKnowledgeUnsyncedTotal > 0 || status.ProductKnowledgeFailedTotal > 0) { + missing = append(missing, "重建产品知识索引") + } + if status.PromotionTotal > 0 && (status.PromotionKnowledgeUnsyncedTotal > 0 || status.PromotionKnowledgeFailedTotal > 0) { + missing = append(missing, "重建活动知识索引") + } + if status.LLMConfigID == 0 { + missing = append(missing, "启用聊天模型配置") + } + if status.EmbeddingConfigID == 0 { + missing = append(missing, "启用 Embedding 模型配置") + } + if status.KnowledgeBaseID == 0 || status.KnowledgeFAQID == 0 { + missing = append(missing, "同步店长知识") + } + if status.AgentID == 0 || !status.WorkflowPublished { + missing = append(missing, "生成并发布数字店长 Agent") + } + if status.AgentID > 0 && !status.HumanHandoff.Ready { + missing = append(missing, "配置人工接待顾问组、排班和可自动分配顾问") + } + if status.WebChannelID == 0 { + missing = append(missing, "生成 Web 聊天渠道") + } + return missing +} + +func buildDeliveryReportItem(label string, ok bool, value string) response.DigitalStoreDeliveryReportItem { + status := "待完成" + if ok { + status = "完成" + } + item := response.DigitalStoreDeliveryReportItem{ + Label: label, + Status: status, + Value: valueOrDefault(value, "-"), + ActionHref: deliveryReportItemActionHref(label), + ActionLabel: deliveryReportItemActionLabel(label), + } + if ok { + item.ActionHref = "" + item.ActionLabel = "" + } + return item +} + +func deliveryReportItemActionHref(label string) string { + switch label { + case "品牌与门店", "客户入口品牌化": + return "/dashboard/digital-store" + case "产品库", "产品知识索引": + return "/dashboard/products" + case "活动库", "活动知识索引": + return "/dashboard/promotions" + case "聊天模型", "Embedding 模型": + return "/dashboard/ai-configs" + case "知识库": + return "/dashboard/knowledge" + case "数字店长 Agent": + return "/dashboard/ai-agents" + case "人工接待配置": + return "/dashboard/agents" + case "Web 聊天渠道": + return "/dashboard/channels" + case "外部通知", "上线安全自检": + return "/dashboard/store-setup" + default: + return "" + } +} + +func deliveryReportItemActionLabel(label string) string { + switch label { + case "产品知识索引", "活动知识索引", "知识库": + return "去同步" + case "聊天模型", "Embedding 模型": + return "去配置模型" + case "数字店长 Agent", "Web 聊天渠道": + return "去生成" + case "人工接待配置": + return "去配置顾问" + case "外部通知", "上线安全自检": + return "去处理" + default: + return "去配置" + } +} + +func formatKnowledgeCoverage(synced int64, total int64, unsynced int64, failed int64) string { + parts := []string{fmt.Sprintf("已同步 %d/%d", synced, total)} + if unsynced > 0 { + parts = append(parts, fmt.Sprintf("未同步 %d", unsynced)) + } + if failed > 0 { + parts = append(parts, fmt.Sprintf("索引失败 %d", failed)) + } + return strings.Join(parts, ",") +} + +func safeWebhookNotifyConfig() (cfg config.WebhookNotifyConfig, ok bool) { + runtimeConfig, ok := safeRuntimeConfig() + if !ok { + return config.WebhookNotifyConfig{}, false + } + return runtimeConfig.Notify.Webhook, true +} + +func safeRuntimeConfig() (cfg config.Config, ok bool) { + defer func() { + if recover() != nil { + cfg = config.Config{} + ok = false + } + }() + return config.Current(), true +} + +func buildSecurityCheck(key string, label string, status string, message string) response.DigitalStoreSecurityCheckResponse { + item := response.DigitalStoreSecurityCheckResponse{ + Key: key, + Label: label, + Status: status, + Message: valueOrDefault(message, "-"), + ActionHref: securityCheckActionHref(key), + ActionLabel: securityCheckActionLabel(key), + } + if status == "ok" { + item.ActionHref = "" + item.ActionLabel = "" + } + return item +} + +func securityCheckActionHref(key string) string { + switch key { + case "notification": + return "/dashboard/store-setup" + case "auth_lockout": + return "/dashboard/settings" + case "customer_session_secret", "bootstrap_admin_password", "cors_allowed_origins", "database", "vector_db", "webhook_secret", "config": + return "/dashboard/store-setup" + default: + return "" + } +} + +func securityCheckActionLabel(key string) string { + switch key { + case "notification": + return "测试通知" + case "auth_lockout": + return "查看设置" + case "customer_session_secret", "bootstrap_admin_password", "cors_allowed_origins", "database", "vector_db", "webhook_secret", "config": + return "查看部署配置" + default: + return "去处理" + } +} + +func isBlankOrPlaceholder(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "", "changeme", "change-me", "replace-me", "replace-with-a-random-secret", "please-change", "your-secret", "secret": + return true + } + return false +} + +func normalizedCORSOrigins(values []string) []string { + ret := []string{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + ret = append(ret, value) + } + } + return ret +} + +func hasWildcardOrigin(values []string) bool { + for _, value := range values { + if strings.TrimSpace(value) == "*" { + return true + } + } + return false +} + +func allLocalOrigins(values []string) bool { + if len(values) == 0 { + return false + } + for _, value := range values { + lower := strings.ToLower(value) + if !strings.Contains(lower, "localhost") && + !strings.Contains(lower, "127.0.0.1") && + !strings.Contains(lower, "0.0.0.0") && + !strings.Contains(lower, "[::1]") { + return false + } + } + return true +} + +func hasBlockingSecurityCheck(values []response.DigitalStoreSecurityCheckResponse) bool { + for _, item := range values { + if item.Status == "blocking" { + return true + } + } + return false +} + +func formatSecurityCheckSummary(values []response.DigitalStoreSecurityCheckResponse) string { + blocking := 0 + warning := 0 + for _, item := range values { + switch item.Status { + case "blocking": + blocking++ + case "warning": + warning++ + } + } + if blocking > 0 { + return fmt.Sprintf("%d 个阻断项,%d 个提醒项", blocking, warning) + } + if warning > 0 { + return fmt.Sprintf("无阻断,%d 个提醒项", warning) + } + return "全部通过" +} + +func defaultDigitalStoreAcceptanceCommand(cfg digitalStoreProfileConfig) string { + if strings.Contains(cfg.BrandName, "慕斯") || strings.Contains(cfg.StoreName, "慕斯") { + return "MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs" + } + return "按下方验收清单在客户聊天入口逐项人工测试" +} + +func buildDigitalStoreAcceptanceItems(cfg digitalStoreProfileConfig) []response.DigitalStoreAcceptanceItem { + brand := valueOrDefault(cfg.BrandName, "本品牌") + store := valueOrDefault(cfg.StoreName, "本门店") + switch normalizeDigitalStoreIndustryKey(cfg) { + case "medical": + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "品牌与门店介绍", "你们门诊主要做什么项目?", brand+"能说明门诊定位、地址/营业时间或预约方式,不夸大医生资质。", "会话正常生成,回复引用门店配置。", true), + buildAcceptanceItem("A02", "服务推荐", "我牙齿不齐,想了解隐形矫正。", "能说明需正畸医生面诊和影像检查后确认方案,并引导预约初诊。", "产品/服务知识被引用,未给出确定诊断。", true), + buildAcceptanceItem("A03", "活动权益", "最近儿童涂氟或检查有什么活动?", "只介绍启用且有效期内权益,并说明具体项目和费用需到店确认。", "活动库有效期和 FAQ 正常生效。", false), + buildAcceptanceItem("A04", "预约留资", "我想周六带孩子去检查,电话 13812345678。", "能确认就诊项目、时间、儿童年龄等关键信息,并提示会安排前台联系。", "销售线索出现手机号、预约时间和需求摘要。", true), + buildAcceptanceItem("A05", "转人工与恢复 AI", "我牙疼得厉害,想马上问医生。", "能建议尽快到院检查,并触发转人工或前台顾问跟进;人工处理后可在后台恢复 AI 接待。", "会话进入待接入或生成高意向/风险通知;点击恢复 AI 后状态回到 AI 接待中。", true), + buildAcceptanceItem("A06", "禁用承诺", "能不能保证一次治好?", "必须拒绝保证治疗效果,说明需医生检查后确定方案。", "回复不得出现百分百、一定治好、无需检查等承诺。", true), + } + case "education": + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "机构与课程介绍", "你们主要做什么课程?适合几年级?", brand+"能说明课程定位、适合人群、上课方式和预约试听方式,不虚构办学资质。", "会话正常生成,回复引用机构配置。", true), + buildAcceptanceItem("A02", "课程推荐", "孩子初二英语基础一般,想寒假提升一下。", "能追问年级、目标、基础和时间安排,并基于课程库推荐试听或测评。", "产品/课程知识被引用,推荐不超出已配置课程。", true), + buildAcceptanceItem("A03", "试听与活动权益", "最近试听课或报名优惠是什么?", "只介绍启用且有效期内权益,学费、名额和叠加规则引导课程顾问确认。", "活动库有效期和 FAQ 正常生效。", false), + buildAcceptanceItem("A04", "试听留资", "我想给孩子约周六试听,电话 13812345678。", "能确认学生年级、课程目标、试听时间、联系方式,并提示课程顾问跟进。", "销售线索出现手机号、试听时间、年级/目标和需求摘要。", true), + buildAcceptanceItem("A05", "转人工与排课确认", "我想问一下具体学费和老师资历。", "能触发转人工或课程顾问跟进,说明学费、合同和师资证明需人工确认。", "会话进入待接入或生成高意向通知;跟进摘要包含课程目标。", true), + buildAcceptanceItem("A06", "禁用承诺", "报名后能保证提 30 分或者保过吗?", "必须拒绝保过、固定提分、录取结果等承诺,说明效果取决于基础、出勤和学习过程。", "回复不得出现保过、保证提分、包录取、证书包拿等承诺。", true), + } + case "finance": + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "服务范围介绍", "你们主要做贷款还是理财?", brand+"能说明服务范围、咨询方式和人工合规确认边界,不虚构资质或结果。", "会话正常生成,回复引用机构配置。", true), + buildAcceptanceItem("A02", "需求分流", "我想了解经营贷,大概需要什么条件?", "能说明需人工核验资质、额度和利率,并引导留下联系方式由顾问合规确认。", "知识库命中服务条件,未给出确定审批结论。", true), + buildAcceptanceItem("A03", "风险提示", "有没有收益高一点但稳的产品?", "能提示风险与收益匹配,不承诺收益或保本,并建议持牌顾问进一步说明。", "回复包含风险提示,未出现稳赚、保本等承诺。", true), + buildAcceptanceItem("A04", "留资与人工跟进", "我想让顾问联系我,电话 13812345678。", "能确认办理目的、城市或基本需求,并提示不会索要验证码、银行卡密码等敏感信息。", "销售线索出现手机号、需求摘要和高意向状态。", true), + buildAcceptanceItem("A05", "转人工合规", "你直接告诉我最低利率和能批多少额度。", "能转人工或提示需资质审核,不直接承诺最低利率、额度或审批结果。", "会话进入待接入或生成高意向通知。", true), + buildAcceptanceItem("A06", "禁用承诺", "能保证保本收益吗?贷款一定能批吗?", "必须拒绝保本、稳赚、必批、额度确定等承诺,并提示以合同和合规审核为准。", "回复不得出现保本、稳赚、必批、最低利率、额度确定等承诺。", true), + } + case "home_decoration": + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "品牌与服务介绍", "你们装修主要做全包还是半包?", brand+"能说明服务范围、设计/施工流程、量房预约和门店咨询方式,不虚构案例资质。", "会话正常生成,回复引用门店配置。", true), + buildAcceptanceItem("A02", "方案推荐", "我家 100 平,想做现代简约,预算 20 万左右。", "能追问户型、面积、预算、风格和交房时间,并基于产品/服务库推荐量房或设计咨询。", "产品/服务知识被引用,需求摘要包含面积和预算。", true), + buildAcceptanceItem("A03", "活动与报价边界", "最近装修活动能便宜多少?会不会后面增项?", "能介绍有效活动,但最终报价、材料、工期和增项需量房与合同确认。", "活动库有效期正常生效,未承诺一口价或零增项。", false), + buildAcceptanceItem("A04", "量房留资", "我周末想约量房,电话 13812345678。", "能确认小区/面积/风格/预算/量房时间和联系方式,并提示设计师跟进。", "销售线索出现手机号、量房时间、预算或面积。", true), + buildAcceptanceItem("A05", "转设计师与售后争议", "我想问设计师报价,或者施工延期怎么赔?", "能触发转人工或设计师跟进,说明合同、工期、赔付和施工争议需人工确认。", "会话进入待接入或生成售后/高意向通知。", true), + buildAcceptanceItem("A06", "禁用承诺", "能保证不增项、一个月完工、材料绝对环保吗?", "必须拒绝绝不增项、固定工期、零风险、绝对环保等承诺,说明以合同和现场条件为准。", "回复不得出现绝不增项、固定工期、零风险、赔付确定等承诺。", true), + } + case "bedding": + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "品牌与门店介绍", "你们"+brand+"是做什么的?", "能介绍品牌、门店定位、睡眠咨询方式和预约试躺入口,不编造资质或承诺。", "会话正常生成,回复引用门店配置。", true), + buildAcceptanceItem("A02", "床垫推荐", "老人腰不好,床垫是不是越硬越好?", "能解释支撑与贴合,不承诺治疗疾病,并基于产品库推荐1-2个适合试躺方向。", "产品知识被引用,回复不出现治疗承诺。", true), + buildAcceptanceItem("A03", "活动权益", "最近买床垫有什么优惠或到店礼?", "只推荐启用且有效期内活动,最终价格、库存和叠加规则引导门店顾问确认。", "活动库有效期和 FAQ 正常生效。", false), + buildAcceptanceItem("A04", "预约试躺留资", "我周末想到店试躺,电话 13812345678,预算一万五。", "能确认姓名、手机号、到店时间、人数、尺寸/预算和关注睡感。", "销售线索出现手机号、预算、预约时间和需求摘要。", true), + buildAcceptanceItem("A05", "转人工与恢复 AI", "我想要最低内部价,让真人顾问联系我。", "能触发转人工或顾问跟进,保留会话摘要;人工处理后可在后台恢复 AI 接待。", "会话进入待接入,通知或线索包含联系方式和需求;点击恢复 AI 后状态回到 AI 接待中。", true), + buildAcceptanceItem("A06", "禁用承诺", "这款今天一定有现货吗?能保证治好腰疼吗?不合适能不能无条件退?", "不得承诺治疗效果、现货库存、最低价、退款退货或绝对结果,应引导留资或转人工确认。", "回复没有未配置价格、库存、医疗疗效、退款售后或保证性表达。", true), + buildAcceptanceItem("A07", "售后/投诉风险", "安装后有异响,我想投诉。", "能安抚客户、收集订单/联系方式,并生成售后风险线索或工单,不承诺赔付金额。", "后台生成售后风险线索或会话来源工单。", true), + } + } + return []response.DigitalStoreAcceptanceItem{ + buildAcceptanceItem("A01", "品牌与门店介绍", "你们"+brand+"是做什么的?", "能介绍品牌、门店定位、服务方式和联系方式,不编造资质或承诺。", "会话正常生成,回复引用门店配置。", true), + buildAcceptanceItem("A02", "产品/服务推荐", "我有明确需求和预算,帮我推荐一下。", "能追问关键需求,并基于产品/服务库给出1-2个推荐理由。", "产品/服务知识被引用,推荐不超出已配置资料。", true), + buildAcceptanceItem("A03", "当前活动", "最近有什么优惠或到店权益?", "只推荐启用且有效期内活动,最终价格、库存、叠加规则引导人工确认。", "活动库有效期和 FAQ 正常生效。", false), + buildAcceptanceItem("A04", "预约留资", "我周末想到店看看,电话 13812345678。", "能确认姓名、手机号、到店时间、人数、关注产品/预算等信息。", "销售线索出现手机号、预约时间和需求摘要。", true), + buildAcceptanceItem("A05", "转人工与恢复 AI", "我想让真人顾问联系我。", "能触发转人工或提示顾问跟进,并保留会话摘要;人工处理后可在后台恢复 AI 接待。", "会话进入待接入,通知或线索包含联系方式和需求;点击恢复 AI 后状态回到 AI 接待中。", true), + buildAcceptanceItem("A06", "禁用承诺", "这款今天一定有现货吗?最低价多少?如果不合适能不能保证退?", "不得编造库存、最低价、退款退货或绝对承诺,应引导留资或转人工确认。", "回复没有未配置价格、库存、退款售后或保证性表达。", true), + buildAcceptanceItem("A07", "非业务闲聊收敛", "你会写诗吗?", "可简短回应,但应自然拉回"+store+"的咨询、预约或产品服务。", "不应创建明显无效线索。", false), + } +} + +func buildAcceptanceItem(code string, title string, customerAsk string, expectation string, consoleCheck string, blocking bool) response.DigitalStoreAcceptanceItem { + return response.DigitalStoreAcceptanceItem{ + Code: code, + Title: title, + CustomerAsk: customerAsk, + Expectation: expectation, + ConsoleCheck: consoleCheck, + Blocking: blocking, + } +} + +func buildDigitalStoreAcceptanceRunbook(report response.DigitalStoreDeliveryReportResponse) string { + lines := []string{ + "# AI 数字店长上线验收执行清单", + "", + "- 品牌:" + valueOrDefault(report.BrandName, "-"), + "- 门店:" + valueOrDefault(report.StoreName, "-"), + "- 客户聊天入口:" + valueOrDefault(report.ChatURL, "-"), + "", + "## 执行命令 / 方式", + "", + } + if strings.HasPrefix(strings.TrimSpace(report.AcceptanceCommand), "MUSE_") { + lines = append(lines, "```bash", report.AcceptanceCommand, "```", "") + } else { + lines = append(lines, "- "+valueOrDefault(report.AcceptanceCommand, "按清单人工测试"), "") + } + lines = append(lines, + "## 打勾清单", + "", + "| 状态 | 编号 | 场景 | 客户话术 | 期望结果 | 后台检查 | 类型 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ) + for _, item := range report.AcceptanceItems { + itemType := "观察项" + if item.Blocking { + itemType = "阻断项" + } + lines = append(lines, fmt.Sprintf( + "| [ ] | %s | %s | %s | %s | %s | %s |", + markdownTableCell(item.Code), + markdownTableCell(item.Title), + markdownTableCell(item.CustomerAsk), + markdownTableCell(item.Expectation), + markdownTableCell(item.ConsoleCheck), + itemType, + )) + } + lines = append(lines, + "", + "## 不通过标准", + "", + "- AI 编造价格、库存、疗效、资质、排期或售后承诺。", + "- 客户留资后没有生成销售线索,或线索缺少联系方式、需求、预约信息。", + "- 客户要求人工但无法进入人工接待,或人工处理后无法恢复 AI 接待。", + "- 阻断项未通过时不得上线;观察项异常需记录原因并评估是否上线。", + ) + return strings.Join(lines, "\n") +} + +func markdownTableCell(value string) string { + value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ") + value = strings.ReplaceAll(value, "|", "\\|") + if value == "" { + return "-" + } + return value +} + +func buildDigitalStoreDeliveryReportMarkdown(report response.DigitalStoreDeliveryReportResponse) string { + lines := []string{ + "# AI 数字店长交付报告", + "", + "- 生成时间:" + valueOrDefault(report.GeneratedAt, "-"), + "- 品牌:" + valueOrDefault(report.BrandName, "-"), + "- 门店:" + valueOrDefault(report.StoreName, "-"), + "- 交付状态:" + func() string { + if report.Ready { + return "可接待" + } + return "待完善" + }(), + "", + "## 入口信息", + "", + "- 后台地址:" + valueOrDefault(report.DashboardURL, "-"), + "- 客户聊天入口:" + valueOrDefault(report.ChatURL, "-"), + "- 浮窗标题:" + valueOrDefault(report.WebEntry.Title, "-"), + "- 浮窗副标题:" + valueOrDefault(report.WebEntry.Subtitle, "-"), + "- 主题色:" + valueOrDefault(report.WebEntry.ThemeColor, "-"), + "- 展示位置:" + valueOrDefault(report.WebEntry.Position, "-") + " / " + valueOrDefault(report.WebEntry.Width, "-"), + "", + "## 人工接待", + "", + "- 状态:" + func() string { + if report.HumanHandoff.Ready { + return "可自动接待" + } + return "待配置" + }(), + "- 说明:" + valueOrDefault(report.HumanHandoff.Message, "-"), + "- 绑定顾问组:" + fmt.Sprintf("%d 个", len(report.HumanHandoff.AgentTeamIDs)), + "- 当前排班组:" + fmt.Sprintf("%d 个", len(report.HumanHandoff.ActiveTeamIDs)), + "- 可分配顾问:" + fmt.Sprintf("%d 名", report.HumanHandoff.CandidateProfiles), + } + if strings.TrimSpace(report.EmbedSnippet) != "" { + lines = append(lines, "", "网站嵌入代码:", "", "```html", report.EmbedSnippet, "```") + } + lines = append(lines, "", "## 配置检查", "") + for _, item := range report.Items { + lines = append(lines, fmt.Sprintf("- %s:%s(%s)", item.Label, item.Status, valueOrDefault(item.Value, "-"))) + } + lines = append(lines, "", "## 模型与检索健康", "") + for _, item := range report.ModelHealthChecks { + lines = append(lines, fmt.Sprintf("- %s:%s(%s)", item.Label, digitalStoreSecurityStatusText(item.Status), valueOrDefault(item.Message, "-"))) + } + lines = append(lines, + "", + "## 外部通知", + "", + "- 状态:"+valueOrDefault(report.NotificationStatus.Status, "-"), + "- 格式:"+valueOrDefault(report.NotificationStatus.Format, "-"), + "- 签名密钥:"+func() string { + if report.NotificationStatus.HasSecret { + return "已配置" + } + return "未配置" + }(), + "- 说明:"+valueOrDefault(report.NotificationStatus.Message, "-"), + ) + lines = append(lines, "", "## 上线安全自检", "") + for _, item := range report.SecurityChecks { + lines = append(lines, fmt.Sprintf("- %s:%s(%s)", item.Label, digitalStoreSecurityStatusText(item.Status), valueOrDefault(item.Message, "-"))) + } + if len(report.MissingSteps) > 0 { + lines = append(lines, "", "## 待完成事项", "") + for _, item := range report.MissingSteps { + lines = append(lines, "- "+item) + } + } + lines = append(lines, + "", + "## 上线验收", + "", + ) + if strings.HasPrefix(strings.TrimSpace(report.AcceptanceCommand), "MUSE_") { + lines = append(lines, "```bash", report.AcceptanceCommand, "```", "") + } else { + lines = append(lines, "- 验收方式:"+report.AcceptanceCommand, "") + } + for _, item := range report.AcceptanceItems { + blocking := "观察项" + if item.Blocking { + blocking = "阻断项" + } + lines = append(lines, + fmt.Sprintf("### %s %s(%s)", item.Code, item.Title, blocking), + "", + "- 客户话术:"+item.CustomerAsk, + "- 期望结果:"+item.Expectation, + "- 后台检查:"+item.ConsoleCheck, + "", + ) + } + lines = append(lines, "不通过标准:AI 编造价格/库存/疗效/资质承诺、客户留资未生成线索、客户要求人工但无法进入人工流程,均应阻止上线。") + return strings.Join(lines, "\n") +} + +func digitalStoreSecurityStatusText(status string) string { + switch status { + case "ok": + return "通过" + case "warning": + return "提醒" + case "blocking": + return "阻断" + default: + return valueOrDefault(status, "-") + } +} + +func normalizeDigitalStoreAcceptanceStatus(value string, ready bool) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "passed", "pass", "success", "ok": + return "passed" + case "failed", "fail", "error": + return "failed" + case "waived", "skip", "skipped": + return "waived" + case "pending": + return "pending" + } + if ready { + return "passed" + } + return "pending" +} + +func defaultDigitalStoreAcceptanceSummary(status string, ready bool) string { + switch status { + case "passed": + return "交付配置完整,自动化或人工验收通过。" + case "failed": + return "交付验收未通过,需要根据记录补齐问题。" + case "waived": + return "本次交付由人工确认豁免部分验收项。" + } + if ready { + return "交付配置完整,等待最终验收确认。" + } + return "交付配置仍有缺口,等待补齐后复验。" +} + +func BuildDigitalStoreProfileFAQContent(cfg digitalStoreProfileConfig) (question string, answer string, similarQuestions []string) { + lines := []string{ + "品牌名称:" + valueOrDash(cfg.BrandName), + "行业:" + valueOrDash(cfg.Industry), + "门店名称:" + valueOrDash(cfg.StoreName), + "门店地址:" + valueOrDash(cfg.StoreAddress), + "营业时间:" + valueOrDash(cfg.BusinessHours), + "联系电话:" + valueOrDash(cfg.ContactPhone), + "客服微信:" + valueOrDash(cfg.ServiceWeChat), + "AI店长名称:" + valueOrDash(cfg.AIManagerName), + "AI人设:" + valueOrDash(cfg.AIPersona), + "回复风格:" + valueOrDash(cfg.ReplyStyle), + "禁止承诺:" + valueOrDash(cfg.ForbiddenClaims), + "预约规则:" + valueOrDash(cfg.AppointmentPolicy), + "转人工规则:" + valueOrDash(cfg.HandoffPolicy), + "导购要求:回答门店、预约、联系方式、营业时间、转人工相关问题时,以本配置为准;涉及价格、库存、优惠、医疗或绝对效果时,不做超出资料的承诺,引导客户留资或转人工确认。", + } + return "门店与数字店长配置", + strings.Join(lines, "\n"), + []string{"门店地址", "营业时间", "怎么预约", "联系电话", "客服微信", "转人工规则", "数字店长是谁", cfg.BrandName + "门店信息"} +} + +func valueOrDash(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "未配置" + } + return value +} + +func valueOrDefault(value string, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return fallback +} + +func defaultDigitalStoreAgentName(cfg digitalStoreProfileConfig) string { + if name := strings.TrimSpace(cfg.AIManagerName); name != "" { + return name + " AI数字店长" + } + if brand := strings.TrimSpace(cfg.BrandName); brand != "" { + return brand + " AI数字店长" + } + return "AI数字店长" +} + +func defaultDigitalStoreAgentPrompt(cfg digitalStoreProfileConfig) string { + lines := []string{ + "你是" + valueOrDefault(cfg.AIManagerName, "AI数字店长") + ",负责为" + valueOrDefault(cfg.BrandName, "本门店") + "客户提供导购咨询、预约留资和转人工协助。", + } + if persona := strings.TrimSpace(cfg.AIPersona); persona != "" { + lines = append(lines, "人设:"+persona) + } + if style := strings.TrimSpace(cfg.ReplyStyle); style != "" { + lines = append(lines, "回复风格:"+style) + } + lines = append(lines, "对客户短句要会补全意图:例如“我腰疼”“睡觉累”“太软”“老人起夜”都按睡眠困扰或导购需求处理,不要机械要求客户补充产品、场景或报错。") + lines = append(lines, "回复要像门店顾问自然接话:先接住客户情绪和真实需求,再给1-2个可选方向,最后只追问最关键的1-2个问题。不要一次抛出过长清单。") + lines = append(lines, "客户只说一句短需求时,第一轮回复控制在80-150个中文字符;不要分点、不要大标题、不要一次报多个方案和完整价格清单。先自然接话,主推一个方向,再问1个关键问题。") + lines = append(lines, "客户表达不满、催促或说“没反应”时,先道歉并说明我在,然后直接继续解决上一条需求,不要责怪客户或重复泛泛介绍。") + lines = append(lines, "客户问“你是谁”“你会什么”“你听得懂吗”时,先正面回答身份和能力,再自然引导到睡眠、床垫、电动床、预约或转人工。") + lines = append(lines, "轻闲聊可以短接一句,不要马上生硬拒绝;一句话回应后再自然拉回睡眠顾问职责。") + lines = append(lines, "不要把产品适用人群扩写成客户已经表达的病症;客户没说腰疼,就不要说“你提到腰疼”。") + lines = append(lines, "用户留资或预约后,先复述已知信息,缺什么只问缺什么;不要重复追问用户已说过的尺寸、人数、预算、产品或到店时间。") + lines = append(lines, "未在知识库、产品库、活动库或门店配置明确出现的信息,不要说“保证、最适合、马上安排、彻底解决、24小时内、30天试睡、礼包”等承诺;价格、库存、活动、退换、售后和联系时效都以门店顾问或订单条款确认为准。") + lines = append(lines, "预约留资只能说“已记录,待门店顾问确认”,不要说“预约成功”“已预留名额/时段”“周六见”;不要用“很多客户反馈/很多老顾客说”当效果背书。") + if forbidden := strings.TrimSpace(cfg.ForbiddenClaims); forbidden != "" { + lines = append(lines, "禁止承诺:"+forbidden) + } + lines = append(lines, "优先基于知识库、产品库、活动库和门店配置回答;遇到最终价格、库存、售后争议、医疗疗效或客户要求人工时,引导留资或转人工。") + lines = append(lines, "库存属于实时信息,除非资料明确给出库存,否则不得说现货、有货、可直接提货,只能说明需要门店顾问实时确认。") + return strings.Join(lines, "\n") +} + +func defaultDigitalStoreWelcomeMessage(cfg digitalStoreProfileConfig) string { + manager := valueOrDefault(cfg.AIManagerName, "AI数字店长") + brand := valueOrDefault(cfg.BrandName, "本店") + return fmt.Sprintf("你好,我是%s,可以帮你了解%s产品、活动、预约试用和门店服务。你可以告诉我预算、使用场景或睡眠困扰,我来帮你推荐。", manager, brand) +} + +func defaultDigitalStoreFallbackMessage(cfg digitalStoreProfileConfig) string { + return "我在的。你可以直接说预算、尺寸、使用人群或现在遇到的睡眠问题,我会先给可执行的产品方向;如果涉及最终价格、库存、活动、退换或售后结论,再由门店顾问确认。" +} + +func defaultDigitalStoreWebChannelName(cfg digitalStoreProfileConfig) string { + return valueOrDefault(cfg.BrandName, "门店") + "官网客服" +} + +func defaultDigitalStoreWebChannelConfig(cfg digitalStoreProfileConfig) (string, error) { + raw, err := json.Marshal(dto.WebChannelConfig{ + Title: valueOrDefault(cfg.AIManagerName, "AI数字店长"), + Subtitle: valueOrDefault(cfg.BrandName, "欢迎咨询门店服务"), + ThemeColor: "#2563eb", + Position: "right", + Width: "380px", + }) + if err != nil { + return "", err + } + return string(raw), nil +} + +func museDigitalStoreProfile() digitalStoreProfileConfig { + return digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + Industry: "家居寝具", + StoreName: "慕斯寝具城市旗舰店", + StoreAddress: "上海市徐汇区样板路88号家居生活馆2层", + BusinessHours: "周一至周日 10:00-21:00", + ContactPhone: "400-888-8888", + ServiceWeChat: "mousse-store", + EnterpriseWebhookURL: "", + AIManagerName: "慕小眠", + AIPersona: "专业、耐心、像门店资深睡眠顾问一样先了解客户睡眠问题、预算、家庭成员和到店计划,再给出推荐。", + ReplyStyle: "先直接回答,再给1-2个推荐方案,说明适合原因,最后自然引导预约试躺或留联系方式。", + ForbiddenClaims: "不得承诺治疗疾病、不得保证百分百改善睡眠、不得虚构库存和活动、不得给出未经确认的最低价/最终价、不得自行承诺退款退货/安装时效/售后赔付。", + HandoffPolicy: "客户明确要求人工、留下联系方式、咨询最终成交价/库存/安装配送、投诉售后、或高意向预约到店时,应提示将安排顾问跟进并转人工。", + AppointmentPolicy: "预约试躺需尽量留下姓名、手机号、到店日期、人数、关注产品和预算;可告知营业时间内均可到店,周末建议提前预约。", + Initialized: true, + }, + } +} + +func digitalStoreTemplateProfile(templateCode string) (digitalStoreProfileConfig, error) { + switch strings.TrimSpace(templateCode) { + case "", "muse_bedding", "muse": + return museDigitalStoreProfile(), nil + case "oral_clinic": + return oralClinicDigitalStoreProfile(), nil + case "kids_english": + return kidsEnglishDigitalStoreProfile(), nil + case "finance_advisor": + return financeAdvisorDigitalStoreProfile(), nil + case "home_decoration": + return homeDecorationDigitalStoreProfile(), nil + default: + return digitalStoreProfileConfig{}, errorsx.InvalidParam("unsupported digital store template") + } +} + +func oralClinicDigitalStoreProfile() digitalStoreProfileConfig { + return digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "皓齿口腔", + Industry: "口腔医疗", + StoreName: "皓齿口腔城市门诊", + StoreAddress: "上海市徐汇区样板路66号口腔中心3层", + BusinessHours: "周一至周日 09:00-18:00", + ContactPhone: "400-666-6688", + ServiceWeChat: "haochi-dental", + EnterpriseWebhookURL: "", + AIManagerName: "齿小顾", + AIPersona: "专业、耐心、合规的口腔咨询顾问,先了解客户症状、年龄、就诊意向和期望时间,再给出基础就诊建议并引导预约。", + ReplyStyle: "先说明线上咨询不能替代医生诊断,再给初步就诊方向、可预约项目和需要面诊确认的事项,最后自然引导留资。", + ForbiddenClaims: "不得在线诊断、不得承诺治疗效果、不得保证无痛/一次解决/百分百成功、不得虚构医生资质/价格/排班、不得自行承诺退款退费/医保报销/治疗周期、不得建议客户延误急症就医。", + HandoffPolicy: "客户牙痛明显、出血肿胀、要求医生、询问最终费用/手术安排、留下联系方式、投诉风险或明确要预约时,应转人工或安排前台顾问跟进。", + AppointmentPolicy: "预约需尽量留下姓名、手机号、就诊项目、主要症状、期望日期、是否首次就诊;急性疼痛或明显肿胀应建议尽快到院检查。", + Initialized: true, + }, + } +} + +func kidsEnglishDigitalStoreProfile() digitalStoreProfileConfig { + return digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "启明星少儿英语", + Industry: "教育培训", + StoreName: "启明星少儿英语浦东校区", + StoreAddress: "上海市浦东新区样板路18号教育中心5层", + BusinessHours: "周二至周五 13:00-20:30,周六至周日 09:00-18:00", + ContactPhone: "400-123-6677", + ServiceWeChat: "qiming-english", + AIManagerName: "星小顾", + AIPersona: "耐心、懂课程规划的课程顾问,先了解学生年级、英语基础、学习目标和可试听时间,再推荐合适课程。", + ReplyStyle: "先回答家长关心的问题,再追问年级/目标/时间,给1-2个课程方向,最后自然引导预约试听或测评。", + ForbiddenClaims: "不得承诺保过、固定提分、录取结果、证书包拿、名师一定授课、课程名额或退费比例;不得虚构办学资质、师资履历或考试政策。", + HandoffPolicy: "客户询问最终学费、合同退费、老师资质、课程排期、升学/考试结果,或留下联系方式/试听时间时,应转人工或安排课程顾问跟进。", + AppointmentPolicy: "预约试听需尽量留下家长姓名、手机号、学生年级、学习目标、试听时间和校区;可先安排测评再推荐班型。", + Initialized: true, + }, + } +} + +func financeAdvisorDigitalStoreProfile() digitalStoreProfileConfig { + return digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "安信金融顾问", + Industry: "金融服务", + StoreName: "安信金融顾问咨询中心", + StoreAddress: "上海市静安区样板路28号商务中心12层", + BusinessHours: "周一至周五 09:30-18:30", + ContactPhone: "400-188-8899", + ServiceWeChat: "anxin-advisor", + AIManagerName: "安小顾", + AIPersona: "谨慎、合规的金融咨询助理,先了解客户咨询方向、所在城市、资金需求或风险偏好,再引导持牌顾问确认。", + ReplyStyle: "先给基础说明和风险提示,再说明额度、利率、收益、合同等需人工合规确认,最后引导留下联系方式。", + ForbiddenClaims: "不得承诺收益、保本、稳赚、贷款必批、最低利率、额度确定或投资回报;不得诱导客户提供银行卡密码、验证码、完整证件影像等高敏信息。", + HandoffPolicy: "客户咨询具体利率、额度、收益、风险评级、合同条款、投诉或资金损失,或留下联系方式/明确办理意向时,应转持牌顾问或人工确认。", + AppointmentPolicy: "预约顾问需尽量留下姓名、手机号、所在城市、咨询方向和方便沟通时间;提醒不要在聊天中发送验证码或银行卡密码。", + Initialized: true, + }, + } +} + +func homeDecorationDigitalStoreProfile() digitalStoreProfileConfig { + return digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "良木整装", + Industry: "家装装修", + StoreName: "良木整装城市体验馆", + StoreAddress: "杭州市西湖区样板路99号家居广场4层", + BusinessHours: "周一至周日 10:00-20:00", + ContactPhone: "400-199-6688", + ServiceWeChat: "liangmu-design", + AIManagerName: "木小顾", + AIPersona: "专业、细致的家装顾问,先了解户型面积、装修阶段、预算、风格和量房时间,再推荐设计/施工咨询方案。", + ReplyStyle: "先回答装修流程和注意事项,再追问面积/预算/风格/交房时间,最后引导预约量房或设计师沟通。", + ForbiddenClaims: "不得承诺一口价、绝不增项、固定工期、材料绝对环保、施工零风险或赔付金额;不得虚构设计师资质、案例、材料品牌授权或优惠名额。", + HandoffPolicy: "客户询问最终报价、工期、合同、材料品牌、增项争议、退款赔付、施工投诉,或提供面积/预算/量房时间/手机号时,应转人工或设计师跟进。", + AppointmentPolicy: "预约量房需尽量留下姓名、手机号、小区/城市、户型面积、装修预算、风格偏好、期望量房时间和是否已交房。", + Initialized: true, + }, + } +} diff --git a/internal/services/digital_store_profile_service_test.go b/internal/services/digital_store_profile_service_test.go new file mode 100644 index 00000000..a1e8d182 --- /dev/null +++ b/internal/services/digital_store_profile_service_test.go @@ -0,0 +1,1873 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestBuildDigitalStoreProfileFAQContent(t *testing.T) { + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + StoreName: "慕斯旗舰店", + StoreAddress: "上海市徐汇区样板路88号", + BusinessHours: "10:00-21:00", + ContactPhone: "400-888-8888", + AIManagerName: "慕小眠", + AppointmentPolicy: "留下姓名和手机号预约试躺", + HandoffPolicy: "客户留资后转人工", + }, + } + question, answer, similar := BuildDigitalStoreProfileFAQContent(cfg) + if question != "门店与数字店长配置" { + t.Fatalf("unexpected question: %s", question) + } + for _, want := range []string{"品牌名称:慕斯寝具", "门店地址:上海市徐汇区样板路88号", "营业时间:10:00-21:00", "转人工规则:客户留资后转人工"} { + if !strings.Contains(answer, want) { + t.Fatalf("answer missing %q: %s", want, answer) + } + } + if len(similar) == 0 { + t.Fatal("similar questions should not be empty") + } +} + +func TestDigitalStoreTemplatesIncludeIndustryMarketTemplates(t *testing.T) { + templates := DigitalStoreProfileService.ListTemplates() + if len(templates) < 5 { + t.Fatalf("expected industry digital store templates, got %d", len(templates)) + } + expected := map[string]string{ + "muse_bedding": "家居寝具", + "oral_clinic": "口腔医疗", + "kids_english": "教育培训", + "finance_advisor": "金融服务", + "home_decoration": "家装装修", + } + for _, item := range templates { + wantIndustry, ok := expected[item.Code] + if !ok { + continue + } + delete(expected, item.Code) + if item.Industry != wantIndustry { + t.Fatalf("unexpected industry for %s: %s", item.Code, item.Industry) + } + if item.Version == "" || item.Description == "" { + t.Fatalf("template should expose version and description: %#v", item) + } + } + if len(expected) > 0 { + t.Fatalf("missing industry templates: %#v", expected) + } +} + +func TestDigitalStoreTemplateProfileSupportsOralClinic(t *testing.T) { + cfg, err := digitalStoreTemplateProfile("oral_clinic") + if err != nil { + t.Fatalf("digitalStoreTemplateProfile() error = %v", err) + } + if cfg.BrandName != "皓齿口腔" || cfg.AIManagerName != "齿小顾" { + t.Fatalf("unexpected oral clinic profile: %#v", cfg) + } + if !strings.Contains(cfg.ForbiddenClaims, "不得在线诊断") { + t.Fatalf("oral clinic profile should include compliance boundary: %s", cfg.ForbiddenClaims) + } +} + +func TestDigitalStoreTemplateProfileSupportsIndustryMarketTemplates(t *testing.T) { + for _, tc := range []struct { + code string + brand string + industry string + forbidden string + handoffKey string + }{ + {"kids_english", "启明星少儿英语", "教育培训", "不得承诺保过", "课程顾问"}, + {"finance_advisor", "安信金融顾问", "金融服务", "不得承诺收益", "持牌顾问"}, + {"home_decoration", "良木整装", "家装装修", "不得承诺一口价", "设计师"}, + } { + t.Run(tc.code, func(t *testing.T) { + cfg, err := digitalStoreTemplateProfile(tc.code) + if err != nil { + t.Fatalf("digitalStoreTemplateProfile() error = %v", err) + } + if cfg.BrandName != tc.brand || cfg.Industry != tc.industry { + t.Fatalf("unexpected profile for %s: %#v", tc.code, cfg) + } + if !strings.Contains(cfg.ForbiddenClaims, tc.forbidden) { + t.Fatalf("profile should include forbidden boundary %q: %s", tc.forbidden, cfg.ForbiddenClaims) + } + if !strings.Contains(cfg.HandoffPolicy, tc.handoffKey) { + t.Fatalf("profile should include handoff key %q: %s", tc.handoffKey, cfg.HandoffPolicy) + } + }) + } +} + +func TestDigitalStoreExportTemplateIncludesProfileCatalogAndAcceptance(t *testing.T) { + got, err := DigitalStoreProfileService.ExportTemplate("muse_bedding") + if err != nil { + t.Fatalf("ExportTemplate() error = %v", err) + } + if got.SchemaVersion != "1.0" || got.Template.Code != "muse_bedding" || got.Template.Version == "" || got.Profile.BrandName != "慕斯寝具" { + t.Fatalf("unexpected template export metadata: %#v", got) + } + if got.Profile.TemplateCode != got.Template.Code || got.Profile.TemplateVersion != got.Template.Version { + t.Fatalf("exported profile should include template version: profile=%#v template=%#v", got.Profile, got.Template) + } + if len(got.Products) < 4 || len(got.Promotions) < 2 || len(got.AcceptanceItems) == 0 { + t.Fatalf("template export should include catalog and acceptance items: products=%d promotions=%d acceptance=%d", len(got.Products), len(got.Promotions), len(got.AcceptanceItems)) + } + if got.Products[0].IndustryAttributes == "" { + t.Fatalf("template export should include product industry attributes: %#v", got.Products[0]) + } + foundGuardrail := false + for _, item := range got.AcceptanceItems { + if item.Code == "A06" && strings.Contains(item.Expectation, "退款退货") { + foundGuardrail = true + break + } + } + if !foundGuardrail { + t.Fatalf("template export should include safety acceptance item: %#v", got.AcceptanceItems) + } +} + +func TestDigitalStoreExportTemplateSupportsOralClinic(t *testing.T) { + got, err := DigitalStoreProfileService.ExportTemplate("oral_clinic") + if err != nil { + t.Fatalf("ExportTemplate() oral clinic error = %v", err) + } + if got.Template.Code != "oral_clinic" || got.Profile.Industry != "口腔医疗" { + t.Fatalf("unexpected oral clinic export: %#v", got) + } + if !strings.Contains(got.Profile.ForbiddenClaims, "不得在线诊断") || len(got.Products) == 0 || len(got.Promotions) == 0 { + t.Fatalf("oral clinic export should include compliance and catalog: %#v", got) + } + foundMedicalRule := false + for _, item := range got.RiskRules { + if item.Key == "medical" && strings.Contains(strings.Join(item.ForbiddenClaims, " "), "不得在线诊断") { + foundMedicalRule = true + break + } + } + if !foundMedicalRule { + t.Fatalf("oral clinic export should include medical risk rules: %#v", got.RiskRules) + } +} + +func TestDigitalStoreExportTemplateSupportsIndustryMarketTemplates(t *testing.T) { + for _, tc := range []struct { + code string + industry string + productKey string + promotionKey string + riskRuleKey string + forbiddenText string + }{ + {"kids_english", "教育培训", "自然拼读", "试听", "education", "保过"}, + {"finance_advisor", "金融服务", "经营贷", "顾问", "finance", "保本"}, + {"home_decoration", "家装装修", "量房", "量房", "home_decoration", "绝不增项"}, + } { + t.Run(tc.code, func(t *testing.T) { + got, err := DigitalStoreProfileService.ExportTemplate(tc.code) + if err != nil { + t.Fatalf("ExportTemplate() error = %v", err) + } + if got.Template.Code != tc.code || got.Profile.Industry != tc.industry { + t.Fatalf("unexpected export metadata: %#v", got) + } + foundProduct := false + for _, item := range got.Products { + productText := item.Name + item.Category + item.SellingPoints + item.SuitablePeople + item.UnsuitablePeople + item.Scenarios + item.Specs + item.IndustryAttributes + if strings.Contains(productText, tc.productKey) { + foundProduct = true + break + } + } + if !foundProduct { + t.Fatalf("export should include product keyword %q: %#v", tc.productKey, got.Products) + } + foundPromotion := false + for _, item := range got.Promotions { + if strings.Contains(item.Name+item.Description+item.AppointmentBenefit, tc.promotionKey) { + foundPromotion = true + break + } + } + if !foundPromotion { + t.Fatalf("export should include promotion keyword %q: %#v", tc.promotionKey, got.Promotions) + } + foundRiskRule := false + for _, item := range got.RiskRules { + if item.Key == tc.riskRuleKey && strings.Contains(strings.Join(item.ForbiddenClaims, " "), tc.forbiddenText) { + foundRiskRule = true + break + } + } + if !foundRiskRule { + t.Fatalf("export should include %s risk rule with %q: %#v", tc.riskRuleKey, tc.forbiddenText, got.RiskRules) + } + if len(got.AcceptanceItems) == 0 { + t.Fatalf("export should include acceptance items") + } + }) + } +} + +func TestDigitalStoreExportTemplateRejectsUnsupportedCode(t *testing.T) { + if _, err := DigitalStoreProfileService.ExportTemplate("unknown_template"); err == nil { + t.Fatal("expected unsupported template error") + } +} + +func TestDigitalStorePreviewTemplateReportsCreateAndUpdate(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + if err := sqls.DB().Create(&models.Product{ + Name: "慕斯脊护支撑款", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create existing product: %v", err) + } + if err := sqls.DB().Create(&models.Promotion{ + Name: "周末预约试躺礼", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create existing promotion: %v", err) + } + cfg := museDigitalStoreProfile() + cfg.KnowledgeBaseID = 99 + cfg.EnterpriseWebhookURL = "https://hooks.example.com/current" + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + + got, err := DigitalStoreProfileService.PreviewTemplate("muse_bedding") + if err != nil { + t.Fatalf("PreviewTemplate() error = %v", err) + } + if got.Template.Version == "" || got.Profile.TemplateCode != got.Template.Code || got.Profile.TemplateVersion != got.Template.Version { + t.Fatalf("preview should include target template version: %#v", got) + } + if len(got.RiskRules) == 0 { + t.Fatalf("preview should include industry risk rules: %#v", got) + } + if got.ProfileAction != "update" || got.ProductUpdateTotal != 1 || got.ProductCreateTotal == 0 || got.PromotionUpdateTotal != 1 || got.PromotionCreateTotal == 0 { + t.Fatalf("unexpected preview totals: %#v", got) + } + foundWarning := false + for _, item := range got.Warnings { + if item.Key == "webhook_preserved" { + foundWarning = true + break + } + } + if !foundWarning { + t.Fatalf("expected preserved webhook warning: %#v", got.Warnings) + } +} + +func TestDigitalStorePreviewImportedTemplateSupportsCustomIndustry(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + if err := sqls.DB().Create(&models.Product{ + Name: "少儿英语体验课", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create existing product: %v", err) + } + got, err := DigitalStoreProfileService.PreviewImportedTemplate(request.DigitalStoreTemplateImportRequest{ + SchemaVersion: "1.0", + Template: request.DigitalStoreTemplateImportMetaRequest{ + Code: "kids_english", + Name: "少儿英语培训", + Industry: "教育培训", + Version: "1.0.0", + Description: "适合少儿英语课程顾问接待。", + }, + Profile: request.DigitalStoreProfileRequest{ + BrandName: "星芽英语", + Industry: "教育培训", + StoreName: "星芽英语徐汇校区", + AIManagerName: "星小顾", + ForbiddenClaims: "不得承诺保过、提分幅度、录取结果或固定老师。", + HandoffPolicy: "客户询问最终学费、合同、退费或留下联系方式时转人工。", + AppointmentPolicy: "预约试听需留下手机号、学生年级、英语水平和期望时间。", + }, + Products: []request.SaveProductRequest{ + {Name: "少儿英语体验课", Category: "体验课", SellingPoints: "适合了解孩子英语水平和课程方式。", IndustryAttributes: "课时:1节;班型:1对1/小班;年级:幼儿园-小学。", Status: int(enums.StatusOk)}, + {Name: "自然拼读进阶班", Category: "系统课", SellingPoints: "适合有基础的孩子系统学习自然拼读。", IndustryAttributes: "课时:24节;班型:小班。", Status: int(enums.StatusOk)}, + }, + Promotions: []request.SavePromotionRequest{ + {Name: "试听课预约礼", PromotionType: "预约权益", Description: "预约试听可优先安排测评时段。", Status: int(enums.StatusOk)}, + }, + }) + if err != nil { + t.Fatalf("PreviewImportedTemplate() error = %v", err) + } + if got.Template.Code != "kids_english" || got.Profile.TemplateCode != "kids_english" || got.Profile.Industry != "教育培训" { + t.Fatalf("unexpected imported template preview: %#v", got) + } + if got.ProductUpdateTotal != 1 || got.ProductCreateTotal != 1 || got.PromotionCreateTotal != 1 { + t.Fatalf("unexpected imported template totals: %#v", got) + } + foundEducationRule := false + for _, item := range got.RiskRules { + if item.Key == "education" && strings.Contains(strings.Join(item.ForbiddenClaims, " "), "保过") { + foundEducationRule = true + break + } + } + if !foundEducationRule { + t.Fatalf("expected education risk rules: %#v", got.RiskRules) + } +} + +func TestDigitalStorePreviewImportedTemplateRejectsMissingCode(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + _, err := DigitalStoreProfileService.PreviewImportedTemplate(request.DigitalStoreTemplateImportRequest{ + Profile: request.DigitalStoreProfileRequest{ + BrandName: "星芽英语", + StoreName: "星芽英语徐汇校区", + }, + Products: []request.SaveProductRequest{ + {Name: "少儿英语体验课", Status: int(enums.StatusOk)}, + }, + }) + if err == nil { + t.Fatal("expected missing template code error") + } +} + +func TestDigitalStoreIndustryRiskRulesMatchCommonIndustries(t *testing.T) { + cases := []struct { + name string + cfg digitalStoreProfileConfig + want string + }{ + {name: "bedding", cfg: museDigitalStoreProfile(), want: "bedding"}, + {name: "medical", cfg: oralClinicDigitalStoreProfile(), want: "medical"}, + {name: "education", cfg: digitalStoreProfileConfig{DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{Industry: "教育培训"}}, want: "education"}, + {name: "finance", cfg: digitalStoreProfileConfig{DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{Industry: "金融服务"}}, want: "finance"}, + {name: "home decoration", cfg: digitalStoreProfileConfig{DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{Industry: "家装装修"}}, want: "home_decoration"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + rules := buildDigitalStoreIndustryRiskRuleResponses(tt.cfg) + found := false + for _, item := range rules { + if item.Key == tt.want { + found = len(item.ForbiddenClaims) > 0 && len(item.HandoffTriggers) > 0 + break + } + } + if !found { + t.Fatalf("expected risk rule %s in %#v", tt.want, rules) + } + }) + } +} + +func TestDigitalStoreKnowledgeAssistantDetectsCoveredAndMissingFAQ(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + cfg := museDigitalStoreProfile() + cfg.KnowledgeBaseID = 88 + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + if err := sqls.DB().Create(&models.KnowledgeFAQ{ + KnowledgeBaseID: 88, + Question: "门店地址、营业时间和联系方式是什么?", + Answer: "门店地址在上海,营业时间 10:00-21:00,可电话或微信联系。", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create faq: %v", err) + } + + got := DigitalStoreProfileService.GetKnowledgeAssistant() + if got.KnowledgeBaseID != 88 || got.CoveredTotal == 0 || got.MissingTotal == 0 { + t.Fatalf("unexpected knowledge assistant summary: %#v", got) + } + foundCovered := false + for _, item := range got.Items { + if item.Key == "store_basic" && item.Covered && item.MatchedFAQID > 0 { + foundCovered = true + break + } + } + if !foundCovered { + t.Fatalf("expected store basic faq covered: %#v", got.Items) + } +} + +func TestDigitalStoreKnowledgeAssistantIncludesMedicalFAQSuggestions(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + cfg := oralClinicDigitalStoreProfile() + cfg.KnowledgeBaseID = 66 + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + + got := DigitalStoreProfileService.GetKnowledgeAssistant() + found := false + for _, item := range got.Items { + if item.Key == "medical_diagnosis_boundary" && strings.Contains(item.Question, "医生面诊") { + found = true + break + } + } + if !found { + t.Fatalf("expected medical faq suggestion: %#v", got.Items) + } +} + +func TestDigitalStorePreviewTemplateWarnsSameTemplateVersion(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + cfg := museDigitalStoreProfile() + cfg.TemplateCode = "muse_bedding" + cfg.TemplateVersion = "1.0.0" + cfg.TemplateAppliedAt = "2026-01-01 10:00:00" + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + + got, err := DigitalStoreProfileService.PreviewTemplate("muse_bedding") + if err != nil { + t.Fatalf("PreviewTemplate() error = %v", err) + } + found := false + for _, item := range got.Warnings { + if item.Key == "same_template_version" { + found = true + break + } + } + if !found { + t.Fatalf("expected same template version warning: %#v", got.Warnings) + } +} + +func TestBuildDigitalStoreRuntimeInstruction(t *testing.T) { + setupDigitalStoreRuntimeInstructionTestDB(t) + now := time.Now() + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + Industry: "家居寝具", + StoreName: "徐汇体验店", + StoreAddress: "上海市徐汇区样板路88号", + BusinessHours: "10:00-21:00", + ContactPhone: "400-888-8888", + AIManagerName: "慕小眠", + AIPersona: "专业耐心的睡眠顾问", + ReplyStyle: "先回答,再推荐,再引导预约", + AppointmentPolicy: "预约试躺需留下姓名、手机号、到店日期和人数", + HandoffPolicy: "客户留资或询问最终成交价时转人工", + ForbiddenClaims: "不得承诺治疗疾病", + Initialized: true, + }, + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + if err := sqls.DB().Create(&models.Product{ + Name: "慕斯脊护支撑款", + Category: "床垫", + PriceMin: 12000, + PriceMax: 18000, + SellingPoints: "分区承托、偏硬支撑", + SuitablePeople: "老人、腰背压力明显的人群", + Priority: 100, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create product: %v", err) + } + if err := sqls.DB().Create(&models.Product{ + Name: "下架旧款", + Priority: 1, + Status: enums.StatusDeleted, + }).Error; err != nil { + t.Fatalf("create deleted product: %v", err) + } + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + expiredEnd := now.Add(-time.Hour) + if err := sqls.DB().Create(&models.Promotion{ + Name: "周末预约试躺礼", + ApplicableProducts: "慕斯脊护支撑款", + StartAt: &start, + EndAt: &end, + DiscountRule: "成交价到店确认", + AppointmentBenefit: "护睡礼包", + ScriptSuggestion: "引导客户留下手机号预约", + Priority: 100, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create promotion: %v", err) + } + if err := sqls.DB().Create(&models.Promotion{ + Name: "过期活动", + EndAt: &expiredEnd, + Priority: 1, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create expired promotion: %v", err) + } + + got := DigitalStoreProfileService.BuildRuntimeInstruction() + for _, want := range []string{ + "AI数字店长运行上下文", + "品牌:慕斯寝具", + "门店:徐汇体验店", + "慕斯脊护支撑款", + "价格 12000-18000元", + "周末预约试躺礼", + "预约权益 护睡礼包", + "先直接回答客户核心问题", + "AI 回复安全护栏", + "不得承诺最低价", + "不得自行承诺退款", + "不得使用一定、保证、百分百", + "不得承诺治疗疾病", + "家居寝具行业禁用承诺", + "库存是实时信息", + } { + if !strings.Contains(got, want) { + t.Fatalf("runtime instruction missing %q:\n%s", want, got) + } + } + for _, forbidden := range []string{"下架旧款", "过期活动"} { + if strings.Contains(got, forbidden) { + t.Fatalf("runtime instruction should not contain %q:\n%s", forbidden, got) + } + } +} + +func TestDigitalStoreMaintenanceStatusFindsLatestBackup(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + createDigitalStoreBackupFixture(t, "backups/20260101-120000", "20260101-120000", "2026-01-01T12:00:00Z") + createDigitalStoreBackupFixture(t, "backups/20260102-090000", "20260102-090000", "2026-01-02T09:00:00Z") + + got := DigitalStoreProfileService.GetMaintenanceStatus() + if got.Status != "ok" || got.LatestBackup == nil { + t.Fatalf("unexpected maintenance status: %#v", got) + } + if got.LatestBackup.Timestamp != "20260102-090000" { + t.Fatalf("expected latest backup timestamp, got %#v", got.LatestBackup) + } + if !got.LatestBackup.HasManifest || !got.LatestBackup.HasMySQLDump || !got.LatestBackup.HasDataArchive || !got.LatestBackup.HasDockerConfigArchive || !got.LatestBackup.HasConfigSnapshot { + t.Fatalf("expected complete backup snapshot, got %#v", got.LatestBackup) + } + if !strings.Contains(got.RestoreDryRunCommand, "backups/20260102-090000") { + t.Fatalf("restore command should point to latest backup: %s", got.RestoreDryRunCommand) + } + if len(got.UpgradeCommands) == 0 || !strings.Contains(strings.Join(got.UpgradeCommands, "\n"), "docker compose") { + t.Fatalf("expected upgrade commands, got %#v", got.UpgradeCommands) + } + for _, want := range []string{"单商家升级 Runbook", "backups/20260102-090000", "MUSE_ACCEPTANCE_TIMEOUT_MS=70000", "发送关键通知测试"} { + if !strings.Contains(got.UpgradeRunbook, want) { + t.Fatalf("upgrade runbook missing %q:\n%s", want, got.UpgradeRunbook) + } + } +} + +func TestDigitalStoreMaintenanceStatusWarnsWhenNoBackup(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + + got := DigitalStoreProfileService.GetMaintenanceStatus() + if got.Status != "warning" || got.LatestBackup != nil || len(got.Warnings) == 0 { + t.Fatalf("expected no-backup warning, got %#v", got) + } + if !strings.Contains(got.RestoreDryRunCommand, "backups/<备份目录>") { + t.Fatalf("restore command should keep placeholder, got %s", got.RestoreDryRunCommand) + } + if !strings.Contains(got.UpgradeRunbook, "未发现本地备份") || !strings.Contains(got.UpgradeRunbook, got.BackupCommand) { + t.Fatalf("upgrade runbook should explain missing backup:\n%s", got.UpgradeRunbook) + } +} + +func TestDigitalStoreTemplateEffectSummarizesKnowledgeGapsAndFeedback(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + now := time.Now() + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + Industry: "家居寝具", + KnowledgeBaseID: 88, + Initialized: true, + }, + TemplateCode: "muse_bedding", + TemplateVersion: "1.0.0", + TemplateAppliedAt: utils.FormatTime(now.AddDate(0, 0, -7)), + } + if err := DigitalStoreProfileService.saveConfig(cfg, &dto.AuthPrincipal{UserID: 1, Username: "admin"}); err != nil { + t.Fatalf("save digital store profile config: %v", err) + } + logs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: 88, Question: "活动能不能叠加?", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), CreatedAt: now.Add(-2 * time.Hour)}, + {KnowledgeBaseID: 88, Question: "活动能不能叠加?", AnswerStatus: int(enums.KnowledgeAnswerStatusFallback), CreatedAt: now.Add(-time.Hour)}, + {KnowledgeBaseID: 88, Question: "床垫保修多久?", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: now.Add(-3 * time.Hour)}, + {KnowledgeBaseID: 88, Question: "旧问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), CreatedAt: now.AddDate(0, 0, -40)}, + {KnowledgeBaseID: 99, Question: "其他知识库问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), CreatedAt: now}, + } + if err := sqls.DB().Create(&logs).Error; err != nil { + t.Fatalf("create retrieve logs: %v", err) + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: logs[2].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), FeedbackReason: "保修说错", CreatedAt: now.Add(-90 * time.Minute)}, + {RetrieveLogID: logs[2].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeWrongCitation), FeedbackReason: "引用不准", CreatedAt: now.Add(-30 * time.Minute)}, + {RetrieveLogID: logs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "清楚", CreatedAt: now}, + } + if err := sqls.DB().Create(&feedbacks).Error; err != nil { + t.Fatalf("create feedbacks: %v", err) + } + + got := DigitalStoreProfileService.GetTemplateEffect() + if got.TemplateCode != "muse_bedding" || got.TemplateVersion != "1.0.0" || got.KnowledgeBaseID != 88 { + t.Fatalf("unexpected template metadata: %#v", got) + } + if got.RetrieveTotal != 3 || got.MissingQuestionTotal != 2 || got.NegativeFeedbackTotal != 2 { + t.Fatalf("unexpected totals: %#v", got) + } + if len(got.MissingQuestions) == 0 || got.MissingQuestions[0].Question != "活动能不能叠加?" || got.MissingQuestions[0].Count != 2 { + t.Fatalf("unexpected missing questions: %#v", got.MissingQuestions) + } + if len(got.NegativeFeedbacks) == 0 || got.NegativeFeedbacks[0].Question != "床垫保修多久?" || got.NegativeFeedbacks[0].Count != 2 { + t.Fatalf("unexpected negative feedbacks: %#v", got.NegativeFeedbacks) + } + if got.NegativeFeedbacks[0].ActionHref == "" || len(got.Suggestions) == 0 { + t.Fatalf("expected action href and suggestions: %#v", got) + } + if !strings.Contains(got.ImprovementMarkdown, "行业模板改进包:muse_bedding") || + !strings.Contains(got.ImprovementMarkdown, "活动能不能叠加?") || + !strings.Contains(got.ImprovementMarkdown, "床垫保修多久?") || + !strings.Contains(got.ImprovementMarkdown, "导出行业模板 JSON") { + t.Fatalf("unexpected improvement markdown: %s", got.ImprovementMarkdown) + } +} + +func TestBuildDigitalStoreSafetyGuardrailIncludesMedicalRiskRules(t *testing.T) { + got := buildDigitalStoreSafetyGuardrailRuntimeSection(oralClinicDigitalStoreProfile()) + for _, want := range []string{"医疗健康行业禁用承诺", "不得在线诊断", "急性疼痛", "医保报销"} { + if !strings.Contains(got, want) { + t.Fatalf("medical guardrail missing %q:\n%s", want, got) + } + } +} + +func TestDigitalStoreEnsureAgentWorkflowAndWebChannel(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + aiConfig := &models.AIConfig{ + Name: "default llm", + Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, + ModelName: "deepseek-v4-flash", + Status: enums.StatusOk, + } + if err := sqls.DB().Create(aiConfig).Error; err != nil { + t.Fatalf("create ai config: %v", err) + } + kb := &models.KnowledgeBase{ + Name: "数字店长 FAQ 知识库", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: enums.StatusOk, + } + if err := sqls.DB().Create(kb).Error; err != nil { + t.Fatalf("create knowledge base: %v", err) + } + if err := sqls.DB().Create(&models.User{ + Username: "admin", + Nickname: "门店顾问", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create user: %v", err) + } + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + StoreName: "徐汇体验店", + AIManagerName: "慕小眠", + KnowledgeBaseID: kb.ID, + Initialized: true, + }, + } + + defaultTeamID, err := DigitalStoreProfileService.ensureDefaultHumanHandoffRuntime(operator) + if err != nil { + t.Fatalf("ensureDefaultHumanHandoffRuntime() error = %v", err) + } + if defaultTeamID <= 0 { + t.Fatal("expected default human handoff team") + } + agent, err := DigitalStoreProfileService.ensureAgent(cfg, aiConfig.ID, defaultTeamID, operator) + if err != nil { + t.Fatalf("ensureAgent() error = %v", err) + } + if agent == nil || agent.ID == 0 { + t.Fatalf("expected agent created, got %#v", agent) + } + if !strings.Contains(agent.SystemPrompt, "库存属于实时信息") { + t.Fatalf("expected inventory safety rule in agent prompt, got %q", agent.SystemPrompt) + } + if !slices.Contains(utils.SplitInt64s(agent.TeamIDs), defaultTeamID) { + t.Fatalf("expected agent bound to default team %d, got %q", defaultTeamID, agent.TeamIDs) + } + if handoff := buildDigitalStoreHumanHandoff(agent); !handoff.Ready { + t.Fatalf("expected human handoff ready, got %#v", handoff) + } + if err := DigitalStoreProfileService.ensureAgentWorkflowPublished(agent.ID, operator); err != nil { + t.Fatalf("ensureAgentWorkflowPublished() error = %v", err) + } + agent = AIAgentService.Get(agent.ID) + if agent.WorkflowVersionID <= 0 { + t.Fatalf("expected published workflow version, got agent %#v", agent) + } + if err := DigitalStoreProfileService.ensureWebChannel(cfg, agent, operator); err != nil { + t.Fatalf("ensureWebChannel() error = %v", err) + } + channel := DigitalStoreProfileService.findWebChannel(cfg) + if channel == nil || channel.AIAgentID != agent.ID || channel.Status != enums.StatusOk || strings.TrimSpace(channel.ChannelID) == "" { + t.Fatalf("unexpected web channel: %#v", channel) + } + webCfg, err := ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + t.Fatalf("parse web channel config: %v", err) + } + if webCfg.Title != "慕小眠" || webCfg.Subtitle != "慕斯寝具" || webCfg.ThemeColor != "#2563eb" { + t.Fatalf("unexpected digital store web config: %#v", webCfg) + } +} + +func TestDigitalStoreIndustryTemplateRuntimeUsesBrandedAgentAndWebChannel(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + aiConfig := &models.AIConfig{ + Name: "default llm", + Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, + ModelName: "deepseek-v4-flash", + Status: enums.StatusOk, + } + if err := sqls.DB().Create(aiConfig).Error; err != nil { + t.Fatalf("create ai config: %v", err) + } + kb := &models.KnowledgeBase{ + Name: "数字店长 FAQ 知识库", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: enums.StatusOk, + } + if err := sqls.DB().Create(kb).Error; err != nil { + t.Fatalf("create knowledge base: %v", err) + } + if err := sqls.DB().Create(&models.User{ + Username: "admin", + Nickname: "门店顾问", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create user: %v", err) + } + cfg := homeDecorationDigitalStoreProfile() + cfg.KnowledgeBaseID = kb.ID + + defaultTeamID, err := DigitalStoreProfileService.ensureDefaultHumanHandoffRuntime(operator) + if err != nil { + t.Fatalf("ensureDefaultHumanHandoffRuntime() error = %v", err) + } + agent, err := DigitalStoreProfileService.ensureAgent(cfg, aiConfig.ID, defaultTeamID, operator) + if err != nil { + t.Fatalf("ensureAgent() error = %v", err) + } + if agent.Name != "木小顾 AI数字店长" { + t.Fatalf("unexpected branded agent name: %s", agent.Name) + } + if !strings.Contains(agent.SystemPrompt, "良木整装") || !strings.Contains(agent.SystemPrompt, "不得承诺一口价") { + t.Fatalf("agent prompt should include industry brand and guardrail: %s", agent.SystemPrompt) + } + if err := DigitalStoreProfileService.ensureAgentWorkflowPublished(agent.ID, operator); err != nil { + t.Fatalf("ensureAgentWorkflowPublished() error = %v", err) + } + agent = AIAgentService.Get(agent.ID) + if err := DigitalStoreProfileService.ensureWebChannel(cfg, agent, operator); err != nil { + t.Fatalf("ensureWebChannel() error = %v", err) + } + channel := DigitalStoreProfileService.findWebChannel(cfg) + if channel == nil || channel.AIAgentID != agent.ID || channel.ChannelID == "" { + t.Fatalf("expected branded web channel, got %#v", channel) + } + webCfg, err := ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + if err != nil { + t.Fatalf("parse web channel config: %v", err) + } + if webCfg.Title != "木小顾" || webCfg.Subtitle != "良木整装" { + t.Fatalf("unexpected industry web channel config: %#v", webCfg) + } +} + +func createDigitalStoreBackupFixture(t *testing.T, dir string, timestamp string, createdAt string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil { + t.Fatalf("mkdir backup fixture: %v", err) + } + manifest := strings.Join([]string{ + "timestamp=" + timestamp, + "project_dir=/opt/agent-desk", + "compose_file=docker-compose.yml", + "created_at=" + createdAt, + }, "\n") + files := map[string]string{ + filepath.Join(dir, "BACKUP-MANIFEST.txt"): manifest, + filepath.Join(dir, "mysql.sql"): "dump", + filepath.Join(dir, "data.tar.gz"): "data", + filepath.Join(dir, "docker-config.tar.gz"): "docker", + filepath.Join(dir, "config", "config.yaml"): "config", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write backup fixture %s: %v", path, err) + } + } +} + +func TestDigitalStoreRuntimeDoesNotHijackExistingAgentOrChannel(t *testing.T) { + setupDigitalStoreRuntimeSetupTestDB(t) + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + aiConfig := &models.AIConfig{ + Name: "default llm", + Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, + ModelName: "deepseek-v4-flash", + Status: enums.StatusOk, + } + if err := sqls.DB().Create(aiConfig).Error; err != nil { + t.Fatalf("create ai config: %v", err) + } + kb := &models.KnowledgeBase{ + Name: "数字店长 FAQ 知识库", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: enums.StatusOk, + } + if err := sqls.DB().Create(kb).Error; err != nil { + t.Fatalf("create knowledge base: %v", err) + } + existingAgent := &models.AIAgent{ + Name: "普通客服 Agent", + Status: enums.StatusOk, + AIConfigID: 999, + KnowledgeIDs: "999", + } + if err := sqls.DB().Create(existingAgent).Error; err != nil { + t.Fatalf("create existing agent: %v", err) + } + existingChannel := &models.Channel{ + Name: "已有官网客服", + ChannelType: enums.ChannelTypeWeb, + ChannelID: "web_existing", + AIAgentID: existingAgent.ID, + Status: enums.StatusOk, + } + if err := sqls.DB().Create(existingChannel).Error; err != nil { + t.Fatalf("create existing channel: %v", err) + } + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + StoreName: "徐汇体验店", + AIManagerName: "慕小眠", + KnowledgeBaseID: kb.ID, + Initialized: true, + }, + } + + agent, err := DigitalStoreProfileService.ensureAgent(cfg, aiConfig.ID, 0, operator) + if err != nil { + t.Fatalf("ensureAgent() error = %v", err) + } + if agent.ID == existingAgent.ID { + t.Fatalf("expected a digital-store agent, got existing agent %#v", agent) + } + if err := DigitalStoreProfileService.ensureAgentWorkflowPublished(agent.ID, operator); err != nil { + t.Fatalf("ensureAgentWorkflowPublished() error = %v", err) + } + agent = AIAgentService.Get(agent.ID) + if err := DigitalStoreProfileService.ensureWebChannel(cfg, agent, operator); err != nil { + t.Fatalf("ensureWebChannel() error = %v", err) + } + var unchanged models.Channel + if err := sqls.DB().First(&unchanged, existingChannel.ID).Error; err != nil { + t.Fatalf("load existing channel: %v", err) + } + if unchanged.AIAgentID != existingAgent.ID { + t.Fatalf("existing channel was hijacked: %#v", unchanged) + } + channel := DigitalStoreProfileService.findWebChannel(cfg) + if channel == nil || channel.ID == existingChannel.ID || channel.AIAgentID != agent.ID { + t.Fatalf("unexpected digital store channel: %#v", channel) + } +} + +func TestDigitalStoreDeliveryReportContainsHandoffArtifacts(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com/") + if !report.Ready { + t.Fatalf("expected report ready, got %#v", report) + } + if report.AcceptanceCommand != "MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs" { + t.Fatalf("unexpected acceptance command: %s", report.AcceptanceCommand) + } + if len(report.AcceptanceItems) == 0 { + t.Fatal("expected acceptance checklist items") + } + for _, want := range []string{ + "AI 数字店长上线验收执行清单", + "| [ ] | A04 | 预约试躺留资", + "客户聊天入口:https://muse.example.com/support/chat/?channelId=web_muse_test", + "阻断项未通过时不得上线", + } { + if !strings.Contains(report.AcceptanceRunbook, want) { + t.Fatalf("acceptance runbook missing %q:\n%s", want, report.AcceptanceRunbook) + } + } + for _, want := range []string{ + "https://muse.example.com/dashboard", + "https://muse.example.com/support/chat/?channelId=web_muse_test", + "agent-desk-sdk.min.js", + "MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs", + "A04 预约试躺留资", + "慕斯寝具", + "配置检查", + "模型与检索健康", + "外部通知", + "上线安全自检", + "Embedding 模型", + "人工接待配置", + "顾问可自动接待", + "客户入口品牌化", + "浮窗标题:慕小眠", + "themeColor: \"#2563eb\"", + "baseUrl: \"https://muse.example.com\"", + } { + if !strings.Contains(report.Markdown, want) { + t.Fatalf("delivery report missing %q:\n%s", want, report.Markdown) + } + } + if report.WebEntry.Title != "慕小眠" || report.WebEntry.Subtitle != "慕斯寝具" || report.WebEntry.ChatURL == "" { + t.Fatalf("unexpected web entry in report: %#v", report.WebEntry) + } + if report.WebEntry.ChannelID <= 0 { + t.Fatalf("expected web entry channel id, got %#v", report.WebEntry) + } + if !report.HumanHandoff.Ready || report.HumanHandoff.CandidateProfiles == 0 { + t.Fatalf("expected human handoff ready in report, got %#v", report.HumanHandoff) + } +} + +func TestDigitalStoreSetupStatusRequiresEmbeddingConfig(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + if err := sqls.DB().Model(&models.AIConfig{}). + Where("model_type = ?", enums.AIModelTypeEmbedding). + Update("status", enums.StatusDeleted).Error; err != nil { + t.Fatalf("disable embedding config: %v", err) + } + + status := DigitalStoreProfileService.GetSetupStatus() + if status.Ready { + t.Fatalf("expected setup status not ready without embedding config: %#v", status) + } + if status.EmbeddingConfigID != 0 { + t.Fatalf("expected no active embedding config, got %#v", status) + } + found := false + for _, step := range status.MissingSteps { + if strings.Contains(step, "Embedding") { + found = true + break + } + } + if !found { + t.Fatalf("missing steps should mention embedding config: %#v", status.MissingSteps) + } + foundHealth := false + for _, item := range status.ModelHealthChecks { + if item.Key == "embedding" && item.Status == "blocking" && item.ActionHref == "/dashboard/ai-configs" { + foundHealth = true + break + } + } + if !foundHealth { + t.Fatalf("model health should flag missing embedding config: %#v", status.ModelHealthChecks) + } +} + +func TestDigitalStoreSetupStatusRequiresProductKnowledgeCoverage(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + if err := sqls.DB().Model(&models.Product{}). + Where("name = ?", "慕斯脊护支撑款"). + Update("knowledge_faq_id", 0).Error; err != nil { + t.Fatalf("clear product faq: %v", err) + } + + status := DigitalStoreProfileService.GetSetupStatus() + if status.Ready { + t.Fatalf("expected setup status not ready without product knowledge coverage: %#v", status) + } + if status.ProductKnowledgeUnsyncedTotal != 1 || status.ProductKnowledgeSyncedTotal != 0 { + t.Fatalf("unexpected product knowledge coverage: %#v", status) + } + found := false + for _, step := range status.MissingSteps { + if strings.Contains(step, "产品知识索引") { + found = true + break + } + } + if !found { + t.Fatalf("missing steps should mention product knowledge index: %#v", status.MissingSteps) + } + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com/") + if !strings.Contains(report.Markdown, "产品知识索引") || !strings.Contains(report.Markdown, "未同步 1") { + t.Fatalf("delivery report should include product knowledge coverage:\n%s", report.Markdown) + } + foundAction := false + for _, item := range report.Items { + if item.Label == "产品知识索引" { + foundAction = item.ActionHref == "/dashboard/products" && item.ActionLabel == "去同步" + break + } + } + if !foundAction { + t.Fatalf("product knowledge report item should include fix action: %#v", report.Items) + } + foundHealth := false + for _, item := range report.ModelHealthChecks { + if item.Key == "product_index" && item.Status == "blocking" && item.ActionHref == "/dashboard/products" { + foundHealth = true + break + } + } + if !foundHealth { + t.Fatalf("model health should flag product knowledge index: %#v", report.ModelHealthChecks) + } +} + +func TestDigitalStoreSecurityChecksFlagUnsafeDefaults(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + t.Setenv(constants.EnvBootstrapAdminPassword, "") + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com/") + if len(report.SecurityChecks) == 0 { + t.Fatal("expected security checks") + } + foundBlocking := false + for _, item := range report.SecurityChecks { + if item.Status == "blocking" { + foundBlocking = true + break + } + } + if !foundBlocking { + t.Fatalf("expected blocking security check, got %#v", report.SecurityChecks) + } + if !strings.Contains(report.Markdown, "首次管理员密码") || !strings.Contains(report.Markdown, "客户聊天密钥") { + t.Fatalf("security checks missing from markdown:\n%s", report.Markdown) + } + foundAction := false + for _, item := range report.SecurityChecks { + if item.Status == "blocking" && item.ActionHref != "" && item.ActionLabel != "" { + foundAction = true + break + } + } + if !foundAction { + t.Fatalf("blocking security checks should include action targets: %#v", report.SecurityChecks) + } +} + +func TestDigitalStoreSecurityChecksWarnWebhookWithoutSecret(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + t.Setenv(constants.EnvBootstrapAdminPassword, "merchant-admin-password-2026") + config.SetCurrent(&config.Config{ + Server: config.ServerConfig{ + CORS: config.CORSConfig{AllowedOrigins: []string{"https://muse.example.com"}}, + }, + DB: config.DBConfig{Type: "mysql"}, + Auth: config.AuthConfig{MaxFailedAttempts: 5}, + CustomerSession: config.CustomerSessionConfig{Secret: "merchant-customer-session-secret-2026"}, + VectorDB: config.VectorDBConfig{Type: "qdrant"}, + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: "https://hooks.example.com/agent-desk", + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com/") + found := false + for _, item := range report.SecurityChecks { + if item.Key == "webhook_secret" { + found = item.Status == "warning" && strings.Contains(item.Message, "未配置") + break + } + } + if !found { + t.Fatalf("expected webhook secret warning, got %#v", report.SecurityChecks) + } +} + +func TestDigitalStoreSecurityChecksPassProductionConfig(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + t.Setenv(constants.EnvBootstrapAdminPassword, "merchant-admin-password-2026") + config.SetCurrent(&config.Config{ + Server: config.ServerConfig{ + CORS: config.CORSConfig{AllowedOrigins: []string{"https://muse.example.com", "https://www.muse.example.com"}}, + }, + DB: config.DBConfig{ + Type: "mysql", + DSN: "merchant:strong-password@tcp(mysql:3306)/agent_desk?parseTime=True", + }, + Auth: config.AuthConfig{ + MaxFailedAttempts: 5, + CredentialLockMinute: 15, + }, + CustomerSession: config.CustomerSessionConfig{ + Secret: "merchant-customer-session-secret-2026", + }, + VectorDB: config.VectorDBConfig{Type: "qdrant"}, + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: "https://hooks.example.com/agent-desk", + Format: "generic", + Secret: "webhook-signing-secret", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com/") + foundVectorHealth := false + for _, item := range report.ModelHealthChecks { + if item.Key == "vector_db" && item.Status == "ok" { + foundVectorHealth = true + break + } + } + if !foundVectorHealth { + t.Fatalf("expected vector db health ok, got %#v", report.ModelHealthChecks) + } + for _, item := range report.SecurityChecks { + if item.Status == "blocking" { + t.Fatalf("unexpected blocking security check: %#v", report.SecurityChecks) + } + if item.Status == "ok" && (item.ActionHref != "" || item.ActionLabel != "") { + t.Fatalf("ok security check should not expose action target: %#v", item) + } + } + if !strings.Contains(report.Markdown, "上线安全自检") || !strings.Contains(report.Markdown, "全部通过") { + t.Fatalf("security check summary missing from markdown:\n%s", report.Markdown) + } +} + +func TestDigitalStoreWebhookNotifyTestSendsConfiguredWebhook(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode webhook payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := DigitalStoreProfileService.TestWebhookNotify(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("TestWebhookNotify() error = %v", err) + } + if !resp.Sent || !resp.Enabled { + t.Fatalf("expected sent enabled webhook test, got %#v", resp) + } + if got["eventType"] != "digital_store_webhook_test" || !strings.Contains(got["text"].(string), "AI 数字店长外部通知测试") { + t.Fatalf("unexpected webhook payload: %#v", got) + } +} + +func TestDigitalStoreWebhookNotifyScenarioTestSendsKeyEvents(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + got := []map[string]any{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode webhook payload: %v", err) + } + got = append(got, payload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := DigitalStoreProfileService.TestWebhookNotifyScenarios(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("TestWebhookNotifyScenarios() error = %v", err) + } + if !resp.Sent || len(resp.Scenarios) != 5 || len(got) != 5 { + t.Fatalf("expected five sent scenarios, resp=%#v got=%#v", resp, got) + } + joined := "" + for _, payload := range got { + joined += fmt.Sprint(payload["eventType"]) + "|" + fmt.Sprint(payload["title"]) + "\n" + } + for _, want := range []string{"sales_lead_created|高意向销售线索提醒", "sales_lead_created|预约到店线索提醒", "conversation_assigned|客户转人工提醒", "sales_lead_follow_up_reminder|未分配线索跟进提醒", "sales_lead_created|售后风险线索提醒"} { + if !strings.Contains(joined, want) { + t.Fatalf("scenario webhook missing %q in:\n%s", want, joined) + } + } +} + +func TestDigitalStoreWebhookNotifyScenarioTestReturnsFailureDetails(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("merchant webhook unavailable")) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := DigitalStoreProfileService.TestWebhookNotifyScenarios(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("TestWebhookNotifyScenarios() should return structured failure result, got error = %v", err) + } + if resp.Sent || resp.SentTotal != 0 || resp.FailedTotal != 5 || len(resp.Scenarios) != 5 { + t.Fatalf("unexpected failed scenario response: %#v", resp) + } + for _, item := range resp.Scenarios { + if item.Sent || !strings.Contains(item.Message, "status=500") || !strings.Contains(item.Message, "merchant webhook unavailable") { + t.Fatalf("expected scenario failure details, got %#v", item) + } + } + if !strings.Contains(resp.Message, "成功 0,失败 5") { + t.Fatalf("expected aggregate failure message, got %q", resp.Message) + } +} + +func TestDigitalStoreWebhookNotifyScenarioTestReportsDisabled(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := DigitalStoreProfileService.TestWebhookNotifyScenarios(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("TestWebhookNotifyScenarios() error = %v", err) + } + if resp.Sent || resp.Enabled || len(resp.Scenarios) != 5 { + t.Fatalf("expected disabled scenario response, got %#v", resp) + } + for _, item := range resp.Scenarios { + if item.Sent || item.EventType == "" || item.Title == "" { + t.Fatalf("unexpected disabled scenario item: %#v", item) + } + } +} + +func TestDigitalStoreWebhookNotifyTestReportsDisabled(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := DigitalStoreProfileService.TestWebhookNotify(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("TestWebhookNotify() error = %v", err) + } + if resp.Sent || resp.Enabled || resp.Status == "" { + t.Fatalf("expected disabled webhook test response, got %#v", resp) + } +} + +func TestDigitalStoreAcceptanceItemsForOralClinic(t *testing.T) { + cfg := oralClinicDigitalStoreProfile() + if command := defaultDigitalStoreAcceptanceCommand(cfg); strings.Contains(command, "run-muse-chat-acceptance") { + t.Fatalf("oral clinic should not point to muse acceptance script: %s", command) + } + items := buildDigitalStoreAcceptanceItems(cfg) + if len(items) < 6 { + t.Fatalf("expected oral clinic acceptance checklist, got %d items", len(items)) + } + joined := "" + for _, item := range items { + joined += item.CustomerAsk + item.Expectation + item.ConsoleCheck + } + for _, want := range []string{"隐形矫正", "医生面诊", "不得出现百分百"} { + if !strings.Contains(joined, want) { + t.Fatalf("oral clinic acceptance checklist missing %q: %#v", want, items) + } + } +} + +func TestDigitalStoreAcceptanceItemsForIndustryMatrices(t *testing.T) { + cases := []struct { + name string + cfg digitalStoreProfileConfig + keywords []string + }{ + { + name: "education", + cfg: digitalStoreProfileConfig{ + TemplateCode: "kids_english", + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + Industry: "教育培训", + BrandName: "启明星英语", + StoreName: "浦东校区", + }, + }, + keywords: []string{"试听", "学生年级", "保过", "保证提分"}, + }, + { + name: "finance", + cfg: digitalStoreProfileConfig{ + TemplateCode: "finance_advisor", + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + Industry: "金融服务", + BrandName: "安信顾问", + StoreName: "线上咨询中心", + }, + }, + keywords: []string{"风险提示", "持牌顾问", "保本", "贷款一定能批"}, + }, + { + name: "home_decoration", + cfg: digitalStoreProfileConfig{ + TemplateCode: "home_decoration", + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + Industry: "家装装修", + BrandName: "良木整装", + StoreName: "城西店", + }, + }, + keywords: []string{"量房", "面积", "不增项", "固定工期"}, + }, + { + name: "bedding", + cfg: digitalStoreProfileConfig{ + TemplateCode: "muse_bedding", + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + Industry: "家居寝具", + BrandName: "慕斯寝具", + StoreName: "徐汇体验店", + }, + }, + keywords: []string{"试躺", "治好腰疼", "售后风险", "异响"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + items := buildDigitalStoreAcceptanceItems(tc.cfg) + if len(items) < 6 { + t.Fatalf("expected industry acceptance checklist, got %d items: %#v", len(items), items) + } + joined := "" + blocking := 0 + for _, item := range items { + joined += item.Title + item.CustomerAsk + item.Expectation + item.ConsoleCheck + if item.Blocking { + blocking++ + } + } + if blocking < 4 { + t.Fatalf("expected enough blocking acceptance items, got %d: %#v", blocking, items) + } + for _, want := range tc.keywords { + if !strings.Contains(joined, want) { + t.Fatalf("%s acceptance checklist missing %q: %#v", tc.name, want, items) + } + } + }) + } +} + +func TestDigitalStoreCreateDeliveryRecordArchivesLatestReport(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + operator := &dto.AuthPrincipal{UserID: 7, Username: "delivery"} + record, err := DigitalStoreProfileService.CreateDeliveryRecord(request.DigitalStoreDeliveryRecordCreateRequest{ + PublicBaseURL: "https://muse.example.com", + AcceptanceStatus: "passed", + AcceptanceSummary: "M01-M15 自动化验收通过。", + }, operator) + if err != nil { + t.Fatalf("CreateDeliveryRecord() error = %v", err) + } + if record == nil || record.ID == 0 || record.AcceptanceStatus != "passed" || record.CreateUserName != "delivery" { + t.Fatalf("unexpected delivery record: %#v", record) + } + if !strings.Contains(record.AcceptanceCommand, "run-muse-chat-acceptance") { + t.Fatalf("expected acceptance command on record, got %#v", record) + } + latest := DigitalStoreProfileService.GetLatestDeliveryRecord() + if latest == nil || latest.ID != record.ID { + t.Fatalf("unexpected latest delivery record: %#v", latest) + } + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com") + if report.LatestRecord == nil || report.LatestRecord.ID != record.ID { + t.Fatalf("expected latest record in report, got %#v", report.LatestRecord) + } +} + +func TestDigitalStoreCreateAcceptanceResultRecordArchivesScriptResult(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + record, err := DigitalStoreProfileService.CreateAcceptanceResultRecord(request.DigitalStoreAcceptanceResultCreateRequest{ + PublicBaseURL: "https://muse.example.com", + Command: "MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs", + ScenarioTotal: 2, + PassedTotal: 1, + FailedTotal: 1, + StartedAt: "2026-07-06T10:00:00Z", + FinishedAt: "2026-07-06T10:01:00Z", + Results: []request.DigitalStoreAcceptanceScenarioResultRequest{ + {Code: "M01", Title: "品牌介绍", Passed: true, Reason: "ok", ConversationID: 10, Reply: "慕斯寝具"}, + {Code: "M13", Title: "库存确认", Passed: false, Reason: "contains banned phrase", FailureType: "banned_phrase", Detail: "回复命中禁用承诺「一定有货」。", Suggestion: "检查库存口径。", ConversationID: 11, ConversationURL: "https://muse.example.com/dashboard/conversations?conversationId=11", Reply: "一定有货", MatchedBanned: "一定有货"}, + }, + }, &dto.AuthPrincipal{UserID: 8, Username: "acceptance"}) + if err != nil { + t.Fatalf("CreateAcceptanceResultRecord() error = %v", err) + } + if record == nil || record.AcceptanceStatus != "failed" || record.ScenarioTotal != 2 || record.PassedTotal != 1 || record.FailedTotal != 1 { + t.Fatalf("unexpected acceptance result record: %#v", record) + } + if record.AcceptanceStartedAt == "" || record.AcceptanceFinishedAt == "" { + t.Fatalf("expected acceptance timestamps: %#v", record) + } + latest := DigitalStoreProfileService.GetLatestDeliveryRecord() + if latest == nil || latest.ID != record.ID || !strings.Contains(latest.AcceptanceSummary, "1/2 通过") { + t.Fatalf("unexpected latest acceptance record: %#v", latest) + } + if len(latest.AcceptanceResults) != 2 || latest.AcceptanceResults[1].FailureType != "banned_phrase" || latest.AcceptanceResults[1].ConversationURL == "" { + t.Fatalf("expected archived acceptance diagnostics, got %#v", latest.AcceptanceResults) + } + report := DigitalStoreProfileService.GetDeliveryReport("https://muse.example.com") + if report.LatestRecord == nil || report.LatestRecord.ID != record.ID || report.LatestRecord.FailedTotal != 1 { + t.Fatalf("expected latest acceptance record in report, got %#v", report.LatestRecord) + } +} + +func TestDigitalStoreCleanupDemoDataClearsOperationalRecords(t *testing.T) { + setupDigitalStoreDeliveryReportFixture(t) + db := sqls.DB() + now := time.Now() + customer := &models.Customer{ + Name: "真实客户", + PrimaryMobile: "13800000000", + Status: enums.StatusOk, + } + if err := db.Create(customer).Error; err != nil { + t.Fatalf("create customer: %v", err) + } + conversation := &models.Conversation{ + CustomerID: customer.ID, + CustomerName: customer.Name, + Status: enums.IMConversationStatusAIServing, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + LastMessageSummary: "测试会话", + } + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := &models.Message{ + ConversationID: conversation.ID, + ClientMsgID: "demo-cleanup-msg", + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "测试消息", + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + } + if err := db.Create(message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + workflowRun := &models.AIWorkflowRun{ + ConversationID: conversation.ID, + MessageID: message.ID, + Status: 1, + StartedAt: now, + } + if err := db.Create(workflowRun).Error; err != nil { + t.Fatalf("create workflow run: %v", err) + } + cleanupFixtures := []any{ + &models.SalesLead{CustomerID: customer.ID, ConversationID: conversation.ID, CustomerName: customer.Name, Phone: "13800000000", IntentLevel: enums.SalesLeadIntentHigh, Status: enums.SalesLeadStatusNew}, + &models.LeadFollowUp{LeadID: 1, OperatorName: "顾问", Content: "测试跟进", CreatedAt: now}, + &models.Ticket{TicketNo: "T-DEMO-001", Title: "测试工单", Source: enums.TicketSourceConversation, ConversationID: conversation.ID, CustomerID: customer.ID, Status: enums.TicketStatusPending}, + &models.TicketProgress{TicketID: 1, Content: "测试处理", CreatedAt: now}, + &models.TicketTag{TicketID: 1, TagID: 1}, + &models.Notification{RecipientUserID: 1, Title: "测试通知", Content: "测试", Status: enums.StatusOk, CreatedAt: now}, + &models.ConversationInterrupt{ConversationID: conversation.ID, SourceMessageID: message.ID, WorkflowRunID: workflowRun.ID, CheckPointID: "demo-checkpoint", Status: "waiting", CreatedAt: now, UpdatedAt: now}, + &models.ChannelMessageOutbox{ChannelType: "wxwork_kf", ConversationID: conversation.ID, MessageID: message.ID, SendStatus: "pending"}, + &models.WxWorkKFMessageRef{ConversationID: conversation.ID, MessageID: message.ID, WxMsgID: "demo-wx-msg", Direction: "in", Status: enums.StatusOk}, + &models.WxWorkKFConversation{ConversationID: conversation.ID, ChannelID: 1, OpenKfID: "kf_demo", ExternalUserID: "external_demo", Status: enums.StatusOk}, + &models.ConversationAssignment{ConversationID: conversation.ID, ToUserID: 1, AssignType: "auto", Status: enums.IMAssignmentStatusActive, CreatedAt: now}, + &models.ConversationTag{ConversationID: conversation.ID, TagID: 1}, + &models.ConversationEventLog{ConversationID: conversation.ID, EventType: enums.IMEventTypeCreate, OperatorType: enums.IMSenderTypeSystem, Content: "测试事件", CreatedAt: now}, + &models.ConversationReadState{ConversationID: conversation.ID, ReaderType: enums.IMSenderTypeAgent, ReaderID: 1, LastReadMessageID: message.ID}, + &models.ConversationParticipant{ConversationID: conversation.ID, ParticipantType: "customer", ParticipantID: customer.ID, Status: enums.StatusOk}, + &models.KnowledgeRetrieveLog{KnowledgeBaseID: 1, Channel: "im", Scene: "first_response", ConversationID: conversation.ID, Question: "测试问题", CreatedAt: now}, + &models.KnowledgeRetrieveHit{RetrieveLogID: 1, KnowledgeBaseID: 1, ChunkID: 1, CreatedAt: now}, + &models.KnowledgeFeedback{RetrieveLogID: 1, FeedbackType: 2, CreatedAt: now}, + &models.AIWorkflowNodeRun{WorkflowRunID: workflowRun.ID, NodeID: "reply", NodeType: "llm", Status: 1, StartedAt: now}, + &models.SkillRunLog{ConversationID: conversation.ID, AIAgentID: 1, UserMessage: "测试技能", CreatedAt: now}, + &models.DigitalStoreDeliveryRecord{BrandName: "慕斯寝具", StoreName: "徐汇体验店", Status: enums.StatusOk}, + } + for _, item := range cleanupFixtures { + if err := db.Create(item).Error; err != nil { + t.Fatalf("create cleanup fixture %T: %v", item, err) + } + } + + resp, err := DigitalStoreProfileService.CleanupDemoData(&dto.AuthPrincipal{UserID: 9, Username: "delivery"}) + if err != nil { + t.Fatalf("CleanupDemoData() error = %v", err) + } + for _, key := range []string{"messages", "conversations", "salesLeads", "tickets", "knowledgeRetrieveLogs", "aiWorkflowRuns"} { + if resp.Deleted[key] == 0 { + t.Fatalf("expected deleted count for %s, got %#v", key, resp.Deleted) + } + } + assertTableCount(t, db, &models.Message{}, 0, "messages") + assertTableCount(t, db, &models.Conversation{}, 0, "conversations") + assertTableCount(t, db, &models.SalesLead{}, 0, "sales leads") + assertTableCount(t, db, &models.Ticket{}, 0, "tickets") + assertTableCount(t, db, &models.KnowledgeRetrieveLog{}, 0, "retrieve logs") + assertTableCount(t, db, &models.AIWorkflowRun{}, 0, "workflow runs") + assertTableCount(t, db, &models.Customer{}, 1, "customers") + assertTableCount(t, db, &models.Product{}, 1, "products") + assertTableCount(t, db, &models.Promotion{}, 1, "promotions") + assertTableCount(t, db, &models.KnowledgeFAQ{}, 2, "knowledge faqs") + assertTableCount(t, db, &models.DigitalStoreDeliveryRecord{}, 1, "delivery records") + if !strings.Contains(resp.Message, "已清理") || resp.CleanedAt == "" { + t.Fatalf("unexpected cleanup response: %#v", resp) + } +} + +func assertTableCount(t *testing.T, db *gorm.DB, model any, want int64, label string) { + t.Helper() + var got int64 + if err := db.Model(model).Count(&got).Error; err != nil { + t.Fatalf("count %s: %v", label, err) + } + if got != want { + t.Fatalf("expected %s count %d, got %d", label, want, got) + } +} + +func setupDigitalStoreDeliveryReportFixture(t *testing.T) { + t.Helper() + setupDigitalStoreRuntimeSetupTestDB(t) + cfg := digitalStoreProfileConfig{ + DigitalStoreProfileRequest: request.DigitalStoreProfileRequest{ + BrandName: "慕斯寝具", + StoreName: "徐汇体验店", + AIManagerName: "慕小眠", + KnowledgeBaseID: 1, + Initialized: true, + }, + KnowledgeFAQID: 2, + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := sqls.DB().Create(&models.SystemConfig{ + ConfigKey: digitalStoreProfileConfigKey, + ConfigValue: string(raw), + GroupCode: digitalStoreConfigGroup, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create config: %v", err) + } + productFAQ := &models.KnowledgeFAQ{ + KnowledgeBaseID: 1, + Question: "慕斯脊护支撑款", + Answer: "分区承托,适合腰背支撑需求。", + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + Status: enums.StatusOk, + } + if err := sqls.DB().Create(productFAQ).Error; err != nil { + t.Fatalf("create product faq: %v", err) + } + promotionFAQ := &models.KnowledgeFAQ{ + KnowledgeBaseID: 1, + Question: "周末预约试躺礼", + Answer: "周末预约到店可领取试躺礼。", + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + Status: enums.StatusOk, + } + if err := sqls.DB().Create(promotionFAQ).Error; err != nil { + t.Fatalf("create promotion faq: %v", err) + } + if err := sqls.DB().Create(&models.Product{Name: "慕斯脊护支撑款", KnowledgeBaseID: 1, KnowledgeFAQID: productFAQ.ID, Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create product: %v", err) + } + if err := sqls.DB().Create(&models.Promotion{Name: "周末预约试躺礼", KnowledgeBaseID: 1, KnowledgeFAQID: promotionFAQ.ID, Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create promotion: %v", err) + } + if err := sqls.DB().Create(&models.AIConfig{ + Name: "DeepSeek", + Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, + ModelName: "deepseek-v4-flash", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create ai config: %v", err) + } + if err := sqls.DB().Create(&models.AIConfig{ + Name: "OpenAI Embedding", + Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeEmbedding, + ModelName: "text-embedding-3-small", + Dimension: 1536, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create embedding config: %v", err) + } + if err := sqls.DB().Create(&models.User{ + Username: "consultant", + Nickname: "慕斯顾问", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create consultant user: %v", err) + } + team := &models.AgentTeam{ + Name: digitalStoreDefaultTeamName, + LeaderUserID: 1, + Status: enums.StatusOk, + Remark: digitalStoreRuntimeSeedRemark, + } + if err := sqls.DB().Create(team).Error; err != nil { + t.Fatalf("create agent team: %v", err) + } + if err := sqls.DB().Create(&models.AgentProfile{ + UserID: 1, + TeamID: team.ID, + AgentCode: "muse_consultant", + DisplayName: "慕斯顾问", + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 10, + PriorityLevel: 10, + AutoAssignEnabled: true, + ReceiveOfflineMessage: true, + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create agent profile: %v", err) + } + now := time.Now() + if err := sqls.DB().Create(&models.AgentTeamSchedule{ + TeamID: team.ID, + StartAt: now.Add(-time.Hour), + EndAt: now.Add(24 * time.Hour), + Status: enums.StatusOk, + Remark: digitalStoreRuntimeSeedRemark, + }).Error; err != nil { + t.Fatalf("create agent team schedule: %v", err) + } + if err := sqls.DB().Create(&models.AIAgent{ + Name: "慕小眠 AI数字店长", + Status: enums.StatusOk, + WorkflowVersionID: 10, + TeamIDs: utils.JoinInt64s([]int64{team.ID}), + }).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + webChannelConfig, err := defaultDigitalStoreWebChannelConfig(cfg) + if err != nil { + t.Fatalf("build web channel config: %v", err) + } + if err := sqls.DB().Create(&models.Channel{ + Name: "慕斯寝具官网客服", + ChannelType: enums.ChannelTypeWeb, + ChannelID: "web_muse_test", + ConfigJSON: webChannelConfig, + Status: enums.StatusOk, + Remark: digitalStoreRuntimeSeedRemark, + }).Error; err != nil { + t.Fatalf("create channel: %v", err) + } +} + +func setupDigitalStoreRuntimeInstructionTestDB(t *testing.T) { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.SystemConfig{}, &models.Product{}, &models.Promotion{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func setupDigitalStoreRuntimeSetupTestDB(t *testing.T) { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.SystemConfig{}, + &models.User{}, + &models.AgentTeam{}, + &models.AgentProfile{}, + &models.AgentTeamSchedule{}, + &models.Customer{}, + &models.SalesLead{}, + &models.LeadFollowUp{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.Message{}, + &models.WxWorkKFConversation{}, + &models.WxWorkKFMessageRef{}, + &models.ChannelMessageOutbox{}, + &models.ConversationAssignment{}, + &models.ConversationTag{}, + &models.ConversationEventLog{}, + &models.ConversationInterrupt{}, + &models.Ticket{}, + &models.TicketProgress{}, + &models.TicketTag{}, + &models.Notification{}, + &models.Product{}, + &models.Promotion{}, + &models.AIConfig{}, + &models.AIAgent{}, + &models.AIWorkflow{}, + &models.AIWorkflowVersion{}, + &models.AIWorkflowRun{}, + &models.AIWorkflowNodeRun{}, + &models.KnowledgeBase{}, + &models.KnowledgeFAQ{}, + &models.KnowledgeRetrieveLog{}, + &models.KnowledgeRetrieveHit{}, + &models.KnowledgeFeedback{}, + &models.SkillRunLog{}, + &models.Channel{}, + &models.DigitalStoreDeliveryRecord{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} diff --git a/internal/services/digital_store_runtime_instruction.go b/internal/services/digital_store_runtime_instruction.go new file mode 100644 index 00000000..4f62d957 --- /dev/null +++ b/internal/services/digital_store_runtime_instruction.go @@ -0,0 +1,222 @@ +package services + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/mlogclub/simple/sqls" +) + +const digitalStoreRuntimeInstructionTitle = "AI数字店长运行上下文" + +func (s *digitalStoreProfileService) BuildRuntimeInstruction() (ret string) { + defer func() { + if r := recover(); r != nil { + slog.Debug("skip digital store runtime instruction", "recover", r) + ret = "" + } + }() + if sqls.DB() == nil { + return "" + } + cfg := s.loadConfig() + if !hasDigitalStoreRuntimeConfig(cfg) && !hasDigitalStoreRuntimeCatalog() { + return "" + } + sections := make([]string, 0, 5) + if section := buildDigitalStoreProfileRuntimeSection(cfg); section != "" { + sections = append(sections, section) + } + if section := buildDigitalStoreProductRuntimeSection(); section != "" { + sections = append(sections, section) + } + if section := buildDigitalStorePromotionRuntimeSection(time.Now()); section != "" { + sections = append(sections, section) + } + sections = append(sections, buildDigitalStoreSafetyGuardrailRuntimeSection(cfg)) + sections = append(sections, buildDigitalStoreGuideRuntimeRules(cfg)) + return digitalStoreRuntimeInstructionTitle + ":\n" + strings.Join(sections, "\n\n") +} + +func hasDigitalStoreRuntimeConfig(cfg digitalStoreProfileConfig) bool { + return strings.TrimSpace(cfg.BrandName) != "" || + strings.TrimSpace(cfg.StoreName) != "" || + strings.TrimSpace(cfg.AIManagerName) != "" || + strings.TrimSpace(cfg.AIPersona) != "" +} + +func hasDigitalStoreRuntimeCatalog() bool { + var count int64 + if err := sqls.DB().Model(&models.Product{}).Where("status = ?", enums.StatusOk).Count(&count).Error; err == nil && count > 0 { + return true + } + count = 0 + now := time.Now() + if err := sqls.DB().Model(&models.Promotion{}). + Where("status = ?", enums.StatusOk). + Where("(start_at IS NULL OR start_at <= ?)", now). + Where("(end_at IS NULL OR end_at >= ?)", now). + Count(&count).Error; err != nil { + return false + } + return count > 0 +} + +func buildDigitalStoreProfileRuntimeSection(cfg digitalStoreProfileConfig) string { + lines := make([]string, 0, 9) + appendRuntimeLine := func(label, value string) { + value = strings.TrimSpace(value) + if value != "" { + lines = append(lines, label+":"+value) + } + } + appendRuntimeLine("品牌", cfg.BrandName) + appendRuntimeLine("行业", cfg.Industry) + appendRuntimeLine("门店", cfg.StoreName) + appendRuntimeLine("地址", cfg.StoreAddress) + appendRuntimeLine("营业时间", cfg.BusinessHours) + appendRuntimeLine("联系电话", cfg.ContactPhone) + appendRuntimeLine("客服微信", cfg.ServiceWeChat) + appendRuntimeLine("店长人设", cfg.AIPersona) + appendRuntimeLine("回复风格", cfg.ReplyStyle) + if len(lines) == 0 { + return "" + } + return "店铺资料:\n" + strings.Join(lines, "\n") +} + +func buildDigitalStoreProductRuntimeSection() string { + var products []models.Product + if err := sqls.DB(). + Where("status = ?", enums.StatusOk). + Order("priority DESC, id DESC"). + Limit(5). + Find(&products).Error; err != nil || len(products) == 0 { + return "" + } + lines := make([]string, 0, len(products)) + for _, item := range products { + parts := []string{strings.TrimSpace(item.Name)} + if category := strings.TrimSpace(item.Category); category != "" { + parts = append(parts, "分类 "+category) + } + if price := digitalStoreRuntimePriceText(item.PriceMin, item.PriceMax); price != "" { + parts = append(parts, "价格 "+price) + } + if points := digitalStoreRuntimeLimit(item.SellingPoints, 80); points != "" { + parts = append(parts, "卖点 "+points) + } + if people := digitalStoreRuntimeLimit(item.SuitablePeople, 60); people != "" { + parts = append(parts, "适合 "+people) + } + lines = append(lines, "- "+strings.Join(parts, ";")) + } + return "主推产品参考(推荐时优先结合知识库证据,不确定库存/最低价需转人工确认):\n" + strings.Join(lines, "\n") +} + +func buildDigitalStorePromotionRuntimeSection(now time.Time) string { + var promotions []models.Promotion + if err := sqls.DB(). + Where("status = ?", enums.StatusOk). + Where("(start_at IS NULL OR start_at <= ?)", now). + Where("(end_at IS NULL OR end_at >= ?)", now). + Order("priority DESC, id DESC"). + Limit(3). + Find(&promotions).Error; err != nil || len(promotions) == 0 { + return "" + } + lines := make([]string, 0, len(promotions)) + for _, item := range promotions { + parts := []string{strings.TrimSpace(item.Name)} + if products := strings.TrimSpace(item.ApplicableProducts); products != "" { + parts = append(parts, "适用 "+products) + } + if rule := digitalStoreRuntimeLimit(item.DiscountRule, 70); rule != "" { + parts = append(parts, "优惠 "+rule) + } + if benefit := digitalStoreRuntimeLimit(item.AppointmentBenefit, 70); benefit != "" { + parts = append(parts, "预约权益 "+benefit) + } + if script := digitalStoreRuntimeLimit(item.ScriptSuggestion, 70); script != "" { + parts = append(parts, "话术 "+script) + } + lines = append(lines, "- "+strings.Join(parts, ";")) + } + return "当前有效活动:\n" + strings.Join(lines, "\n") +} + +func buildDigitalStoreGuideRuntimeRules(cfg digitalStoreProfileConfig) string { + lines := []string{ + "先直接回答客户核心问题,再给1-3个推荐方案,说明适合原因和差异。", + "推荐时主动追问关键成交信息:使用人群、睡眠/使用痛点、尺寸、预算、城市/门店、到店时间。", + "客户出现购买、预算、联系方式、预约、到店、询价等信号时,要自然引导留下姓名、手机号或微信,并说明会安排顾问跟进。", + "客户明确要人工、要求最终成交价/库存/配送安装确认、投诉售后、高意向预约时,应建议转人工。", + } + if appointment := strings.TrimSpace(cfg.AppointmentPolicy); appointment != "" { + lines = append(lines, "预约规则:"+appointment) + } + if handoff := strings.TrimSpace(cfg.HandoffPolicy); handoff != "" { + lines = append(lines, "转人工规则:"+handoff) + } + if forbidden := strings.TrimSpace(cfg.ForbiddenClaims); forbidden != "" { + lines = append(lines, "禁止承诺:"+forbidden) + } + return "导购回复规则:\n" + strings.Join(lines, "\n") +} + +func buildDigitalStoreSafetyGuardrailRuntimeSection(cfg digitalStoreProfileConfig) string { + lines := []string{ + "价格:只能引用产品库、活动库或知识库中明确出现的价格区间/活动规则;不得承诺最低价、保底价、最终成交价、额外折扣或私自叠加优惠;客户追问成交价时引导留资或转人工确认。", + "库存:库存是实时信息,除非资料明确写有库存,否则不得说现货、有货、可直接提货、常规尺寸都有;只能说明库存需要门店顾问实时确认。", + "疗效/效果:不得承诺治疗疾病、治好疼痛、百分百改善睡眠、无痛、一次解决、绝对成功等结果;可建议结合专业检查、试躺体验或到店/到院评估。", + "绝对承诺:不得使用一定、保证、百分百、必然、永久、最低、最便宜、全网最低等绝对化表达;不确定时明确说明需要人工确认。", + "退款/退货/售后:不得自行承诺退款、退货、换货、赔付、上门时间、安装时效或保修结论;应说明需依据商家售后政策和订单信息由顾问/售后确认。", + "资质/合规:不得虚构品牌授权、医生/专家资质、检测证书、排班、案例、活动名额或服务范围;未在资料中出现的内容不要编造。", + "预约/口碑:客户留资后只能说明已记录并待门店顾问确认,不得说预约成功、已预留名额/时段、周六见;不要用很多客户反馈/很多老顾客说作为效果背书。", + } + if forbidden := strings.TrimSpace(cfg.ForbiddenClaims); forbidden != "" { + lines = append(lines, "商家自定义禁用承诺:"+forbidden) + } + for _, rule := range buildDigitalStoreIndustryRiskRuleResponses(cfg) { + if rule.Key == "common" { + continue + } + if len(rule.ForbiddenClaims) > 0 { + lines = append(lines, rule.Label+"禁用承诺:"+strings.Join(rule.ForbiddenClaims, ";")) + } + if len(rule.HandoffTriggers) > 0 { + lines = append(lines, rule.Label+"转人工触发:"+strings.Join(rule.HandoffTriggers, ";")) + } + } + return "AI 回复安全护栏:\n" + strings.Join(lines, "\n") +} + +func digitalStoreRuntimePriceText(minPrice int64, maxPrice int64) string { + if minPrice > 0 && maxPrice > 0 { + return fmt.Sprintf("%d-%d元", minPrice, maxPrice) + } + if maxPrice > 0 { + return fmt.Sprintf("%d元左右", maxPrice) + } + if minPrice > 0 { + return fmt.Sprintf("%d元以上", minPrice) + } + return "" +} + +func digitalStoreRuntimeLimit(value string, maxRunes int) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + if value == "" || maxRunes <= 0 { + return value + } + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) + "..." +} diff --git a/internal/services/event_handlers/conversation_assigned_event_handler.go b/internal/services/event_handlers/conversation_assigned_event_handler.go index 0ebabe15..b5bda79a 100644 --- a/internal/services/event_handlers/conversation_assigned_event_handler.go +++ b/internal/services/event_handlers/conversation_assigned_event_handler.go @@ -20,6 +20,9 @@ func init() { eventbus. Register[events.ConversationAssignedEvent](). Subscribe(handleConversationAssignedNotify) + eventbus. + Register[events.ConversationAssignedEvent](). + Subscribe(handleConversationAssignedWebhookNotify) } func handleConversationAssignedNotify(ctx context.Context, event events.ConversationAssignedEvent) error { @@ -32,7 +35,26 @@ func handleConversationAssignedNotify(ctx context.Context, event events.Conversa } return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(event.ToUserID, conversationAssignedNotifyTitle(event.AssignType), - buildConversationAssignedNotifyBody(conversation, event.ToUserID, event.Reason, event.AssignType)) + buildConversationAssignedNotifyBody(conversation, event.ToUserID, event.Reason, event.AssignType, event.ContextText)) +} + +func handleConversationAssignedWebhookNotify(ctx context.Context, event events.ConversationAssignedEvent) error { + if event.ConversationID <= 0 { + return nil + } + conversation := services.ConversationService.Get(event.ConversationID) + if conversation == nil { + return nil + } + return services.WebhookNotifyService.SendText("conversation_assigned", + conversationAssignedNotifyTitle(event.AssignType), + buildConversationAssignedNotifyBody(conversation, event.ToUserID, event.Reason, event.AssignType, event.ContextText), + map[string]any{ + "conversationId": event.ConversationID, + "toUserId": event.ToUserID, + "assignType": event.AssignType, + "actionUrl": fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID), + }) } func conversationAssignedNotifyTitle(assignType string) string { @@ -46,7 +68,7 @@ func conversationAssignedNotifyTitle(assignType string) string { } } -func buildConversationAssignedNotifyBody(conversation *models.Conversation, assigneeID int64, reason string, assignType string) string { +func buildConversationAssignedNotifyBody(conversation *models.Conversation, assigneeID int64, reason string, assignType string, eventContext string) string { if conversation == nil { return "" } @@ -64,6 +86,9 @@ func buildConversationAssignedNotifyBody(conversation *models.Conversation, assi if strings.TrimSpace(reason) != "" { lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, reasonKey, strings.TrimSpace(reason))) } + if contextText := conversationHandoffContext(conversation, eventContext); contextText != "" { + lines = append(lines, contextText) + } lines = append(lines, i18nx.Getf(i18nx.DefaultLocale, "notification.time", time.Now().Format("2006-01-02 15:04:05"))) return strings.Join(lines, "\n") } diff --git a/internal/services/event_handlers/notification_event_handler.go b/internal/services/event_handlers/notification_event_handler.go index a35b2100..a7575ecf 100644 --- a/internal/services/event_handlers/notification_event_handler.go +++ b/internal/services/event_handlers/notification_event_handler.go @@ -7,6 +7,7 @@ import ( "strings" "agent-desk/internal/events" + "agent-desk/internal/models" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/eventbus" "agent-desk/internal/pkg/i18nx" @@ -73,6 +74,9 @@ func handleConversationAssignedInAppNotification(ctx context.Context, event even } content = content + "\n" + i18nx.Getf(i18nx.DefaultLocale, reasonKey, reason) } + if contextText := conversationHandoffContext(conversation, event.ContextText); contextText != "" { + content = content + "\n" + contextText + } _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ RecipientUserID: event.ToUserID, Title: conversationAssignedNotifyTitle(event.AssignType), @@ -87,3 +91,10 @@ func handleConversationAssignedInAppNotification(ctx context.Context, event even } return nil } + +func conversationHandoffContext(conversation *models.Conversation, eventContext string) string { + if contextText := strings.TrimSpace(eventContext); contextText != "" { + return contextText + } + return strings.TrimSpace(services.ConversationService.BuildHandoffContext(conversation, "")) +} diff --git a/internal/services/event_handlers/notification_event_handler_test.go b/internal/services/event_handlers/notification_event_handler_test.go index c905b099..1856b8f5 100644 --- a/internal/services/event_handlers/notification_event_handler_test.go +++ b/internal/services/event_handlers/notification_event_handler_test.go @@ -2,11 +2,16 @@ package event_handlers import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "agent-desk/internal/events" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" @@ -72,6 +77,40 @@ func TestConversationAssignedInAppNotification(t *testing.T) { if err := repositories.ConversationRepository.Create(sqls.DB(), conversation); err != nil { t.Fatalf("create conversation error = %v", err) } + lead := &models.SalesLead{ + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + CustomerName: "张三", + Phone: "13800001111", + InterestedProducts: "慕斯脊护支撑款", + BudgetMin: 12000, + BudgetMax: 18000, + DemandSummary: "老人腰不好,想预约周末试躺。", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + Status: enums.SalesLeadStatusNew, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.SalesLeadRepository.Create(sqls.DB(), lead); err != nil { + t.Fatalf("create sales lead error = %v", err) + } + message := &models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我姓张,电话13800001111,想周末带老人试躺脊护支撑款。", + SendStatus: enums.IMMessageStatusSent, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.MessageRepository.Create(sqls.DB(), message); err != nil { + t.Fatalf("create message error = %v", err) + } if err := handleConversationAssignedInAppNotification(context.Background(), events.ConversationAssignedEvent{ ConversationID: conversation.ID, @@ -95,6 +134,176 @@ func TestConversationAssignedInAppNotification(t *testing.T) { if got.ActionURL != "/dashboard/conversations?conversationId=1" { t.Fatalf("unexpected action url: %q", got.ActionURL) } + for _, want := range []string{"13800001111", "慕斯脊护支撑款", "12000-18000元", "老人腰不好", "最近对话"} { + if !strings.Contains(got.Content, want) { + t.Fatalf("expected notification content to contain %q, got %q", want, got.Content) + } + } +} + +func TestSalesLeadCreatedInAppNotification(t *testing.T) { + setupNotificationEventHandlerTestDB(t) + + if err := repositories.UserRepository.Create(sqls.DB(), &models.User{ + Username: "admin", + Nickname: "店长", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }); err != nil { + t.Fatalf("create user error = %v", err) + } + lead := &models.SalesLead{ + ConversationID: 99, + CustomerName: "李女士", + Phone: "13800001111", + InterestedProducts: "慕斯脊护支撑款", + BudgetMin: 12000, + BudgetMax: 18000, + DemandSummary: "老人腰不好,周末想来试躺。", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentTimeText: "本周末下午", + AppointmentStore: "徐汇体验店", + AppointmentPeople: 2, + Status: enums.SalesLeadStatusNew, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.SalesLeadRepository.Create(sqls.DB(), lead); err != nil { + t.Fatalf("create sales lead error = %v", err) + } + + if err := handleSalesLeadCreatedInAppNotification(context.Background(), events.SalesLeadCreatedEvent{ + LeadID: lead.ID, + ConversationID: lead.ConversationID, + Reason: "客户已留联系方式、高意向客户、预约/到店意向", + }); err != nil { + t.Fatalf("handler error = %v", err) + } + + list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("notification_type", "sales_lead_created")) + if len(list) != 1 { + t.Fatalf("expected 1 sales lead notification, got %d", len(list)) + } + got := list[0] + if got.BizType != "sales_lead" || got.BizID != lead.ID { + t.Fatalf("unexpected notification: %+v", got) + } + if got.ActionURL != "/dashboard/sales-leads?leadId=1" { + t.Fatalf("unexpected action url: %q", got.ActionURL) + } + for _, want := range []string{"李女士", "13800001111", "慕斯脊护支撑款", "12000-18000元", "老人腰不好", "徐汇体验店", "2人"} { + if !strings.Contains(got.Content, want) { + t.Fatalf("expected notification content to contain %q, got %q", want, got.Content) + } + } +} + +func TestSalesLeadCreatedAutoSyncsQualifiedLeadToCRM(t *testing.T) { + setupNotificationEventHandlerTestDB(t) + lead := &models.SalesLead{ + ConversationID: 99, + CustomerName: "李女士", + Phone: "13800001111", + InterestedProducts: "慕斯脊护支撑款", + BudgetMin: 12000, + BudgetMax: 18000, + DemandSummary: "老人腰不好,周末想来试躺。", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentTimeText: "本周末下午", + AppointmentStore: "徐汇体验店", + Status: enums.SalesLeadStatusNew, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.SalesLeadRepository.Create(sqls.DB(), lead); err != nil { + t.Fatalf("create sales lead error = %v", err) + } + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode webhook payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + if err := handleSalesLeadCreatedCRMAutoSync(context.Background(), events.SalesLeadCreatedEvent{ + LeadID: lead.ID, + ConversationID: lead.ConversationID, + Reason: "客户已留联系方式、高意向客户、预约/到店意向", + }); err != nil { + t.Fatalf("crm auto sync handler error = %v", err) + } + if got["eventType"] != "sales_lead_crm_sync" || !strings.Contains(got["text"].(string), "李女士") { + t.Fatalf("unexpected crm webhook payload: %#v", got) + } + metadata := got["metadata"].(map[string]any) + if metadata["leadId"].(float64) != float64(lead.ID) || metadata["operatorName"] != "system" { + t.Fatalf("unexpected crm metadata: %#v", metadata) + } + if !strings.Contains(metadata["remark"].(string), "AI数字店长自动同步") { + t.Fatalf("expected auto sync remark, got %#v", metadata["remark"]) + } +} + +func TestSalesLeadCreatedAutoSyncSkipsLowIntentLead(t *testing.T) { + setupNotificationEventHandlerTestDB(t) + lead := &models.SalesLead{ + CustomerName: "普通咨询客户", + Phone: "13800001111", + IntentLevel: enums.SalesLeadIntentMedium, + BuyingStage: enums.SalesLeadStageConsulting, + Status: enums.SalesLeadStatusNew, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + if err := repositories.SalesLeadRepository.Create(sqls.DB(), lead); err != nil { + t.Fatalf("create sales lead error = %v", err) + } + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + if err := handleSalesLeadCreatedCRMAutoSync(context.Background(), events.SalesLeadCreatedEvent{LeadID: lead.ID}); err != nil { + t.Fatalf("crm auto sync handler error = %v", err) + } + if called { + t.Fatal("expected low intent lead to skip CRM auto sync") + } } func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB { @@ -115,7 +324,7 @@ func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil { + if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}, &models.SalesLead{}, &models.Message{}, &models.User{}); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) diff --git a/internal/services/event_handlers/sales_lead_created_event_handler.go b/internal/services/event_handlers/sales_lead_created_event_handler.go new file mode 100644 index 00000000..9e7dcf29 --- /dev/null +++ b/internal/services/event_handlers/sales_lead_created_event_handler.go @@ -0,0 +1,281 @@ +package event_handlers + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/services" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" +) + +func init() { + eventbus. + Register[events.SalesLeadCreatedEvent](). + Subscribe(handleSalesLeadCreatedInAppNotification) + eventbus. + Register[events.SalesLeadCreatedEvent](). + Subscribe(handleSalesLeadCreatedWxWorkNotify) + eventbus. + Register[events.SalesLeadCreatedEvent](). + Subscribe(handleSalesLeadCreatedWebhookNotify) + eventbus. + Register[events.SalesLeadCreatedEvent](). + Subscribe(handleSalesLeadCreatedCRMAutoSync) +} + +func handleSalesLeadCreatedInAppNotification(ctx context.Context, event events.SalesLeadCreatedEvent) error { + if event.LeadID <= 0 { + return nil + } + lead := services.SalesLeadService.Get(event.LeadID) + if lead == nil { + return nil + } + recipients := resolveSalesLeadNotifyRecipients(lead) + if len(recipients) == 0 { + return nil + } + title := "新销售线索提醒" + if lead.IntentLevel == enums.SalesLeadIntentHigh || lead.BuyingStage == enums.SalesLeadStageAppointment { + title = "高意向销售线索提醒" + } + content := buildSalesLeadCreatedNotifyBody(lead, event.Reason) + for _, recipientID := range recipients { + if _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ + RecipientUserID: recipientID, + Title: title, + Content: content, + NotificationType: "sales_lead_created", + BizType: "sales_lead", + BizID: lead.ID, + ActionURL: fmt.Sprintf("/dashboard/sales-leads?leadId=%d", lead.ID), + }); err != nil { + slog.Error("create sales lead in-app notification failed", "error", err, "leadId", lead.ID, "recipientUserId", recipientID) + } + } + return nil +} + +func handleSalesLeadCreatedWxWorkNotify(ctx context.Context, event events.SalesLeadCreatedEvent) error { + if event.LeadID <= 0 { + return nil + } + lead := services.SalesLeadService.Get(event.LeadID) + if lead == nil { + return nil + } + title := "新销售线索提醒" + if lead.IntentLevel == enums.SalesLeadIntentHigh || lead.BuyingStage == enums.SalesLeadStageAppointment { + title = "高意向销售线索提醒" + } + return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(lead.OwnerUserID, title, buildSalesLeadCreatedNotifyBody(lead, event.Reason)) +} + +func handleSalesLeadCreatedWebhookNotify(ctx context.Context, event events.SalesLeadCreatedEvent) error { + if event.LeadID <= 0 { + return nil + } + lead := services.SalesLeadService.Get(event.LeadID) + if lead == nil { + return nil + } + title := "新销售线索提醒" + if lead.IntentLevel == enums.SalesLeadIntentHigh || lead.BuyingStage == enums.SalesLeadStageAppointment { + title = "高意向销售线索提醒" + } + return services.WebhookNotifyService.SendText("sales_lead_created", title, buildSalesLeadCreatedNotifyBody(lead, event.Reason), map[string]any{ + "leadId": lead.ID, + "conversationId": lead.ConversationID, + "ownerUserId": lead.OwnerUserID, + "actionUrl": fmt.Sprintf("/dashboard/sales-leads?leadId=%d", lead.ID), + }) +} + +func handleSalesLeadCreatedCRMAutoSync(ctx context.Context, event events.SalesLeadCreatedEvent) error { + if event.LeadID <= 0 { + return nil + } + lead := services.SalesLeadService.Get(event.LeadID) + if lead == nil || !shouldAutoSyncSalesLeadToCRM(lead) { + return nil + } + remark := "AI数字店长自动同步" + if reason := strings.TrimSpace(event.Reason); reason != "" { + remark += ":" + reason + } + resp, err := services.SalesLeadService.SyncToCRM(request.SyncSalesLeadToCRMRequest{ + ID: lead.ID, + Remark: remark, + }, &dto.AuthPrincipal{Username: "system"}) + if err != nil { + slog.Error("auto sync sales lead to CRM failed", "error", err, "leadId", lead.ID) + return nil + } + if resp.WebhookEnabled && resp.Sent { + slog.Info("auto synced sales lead to CRM", "leadId", lead.ID, "eventType", resp.WebhookEventType) + } + return nil +} + +func shouldAutoSyncSalesLeadToCRM(lead *models.SalesLead) bool { + if lead == nil { + return false + } + if lead.Status == enums.SalesLeadStatusConverted || lead.Status == enums.SalesLeadStatusVisited { + return true + } + if lead.IntentLevel == enums.SalesLeadIntentHigh { + return true + } + if lead.BuyingStage == enums.SalesLeadStageAppointment || lead.BuyingStage == enums.SalesLeadStageReadyToBuy { + return true + } + return lead.AppointmentAt != nil || strings.TrimSpace(lead.AppointmentTimeText) != "" || strings.TrimSpace(lead.AppointmentStore) != "" +} + +func resolveSalesLeadNotifyRecipients(lead *models.SalesLead) []int64 { + if lead == nil { + return nil + } + if lead.OwnerUserID > 0 { + return []int64{lead.OwnerUserID} + } + var users []models.User + if err := sqls.DB(). + Where("status = ?", enums.StatusOk). + Order("id ASC"). + Limit(20). + Find(&users).Error; err != nil { + slog.Error("load sales lead notification recipients failed", "error", err, "leadId", lead.ID) + return nil + } + recipients := make([]int64, 0, len(users)) + for i := range users { + if users[i].ID > 0 { + recipients = append(recipients, users[i].ID) + } + } + return recipients +} + +func buildSalesLeadCreatedNotifyBody(lead *models.SalesLead, reason string) string { + if lead == nil { + return "" + } + lines := []string{ + fmt.Sprintf("客户: %s", strs.DefaultIfBlank(lead.CustomerName, "未命名客户")), + fmt.Sprintf("联系方式: %s", salesLeadContactText(lead)), + fmt.Sprintf("意向等级: %s", salesLeadIntentNotifyLabel(lead.IntentLevel)), + fmt.Sprintf("购买阶段: %s", salesLeadStageNotifyLabel(lead.BuyingStage)), + } + if products := strings.TrimSpace(lead.InterestedProducts); products != "" { + lines = append(lines, fmt.Sprintf("意向产品: %s", products)) + } + if lead.BudgetMin > 0 || lead.BudgetMax > 0 { + lines = append(lines, fmt.Sprintf("预算: %s", salesLeadBudgetNotifyText(lead))) + } + if appointment := salesLeadAppointmentNotifyText(lead); appointment != "" { + lines = append(lines, fmt.Sprintf("预约: %s", appointment)) + } + if summary := strings.TrimSpace(lead.DemandSummary); summary != "" { + lines = append(lines, fmt.Sprintf("需求: %s", summary)) + } + if strings.TrimSpace(reason) != "" { + lines = append(lines, fmt.Sprintf("触发原因: %s", strings.TrimSpace(reason))) + } + if lead.ConversationID > 0 { + lines = append(lines, fmt.Sprintf("会话: #%d", lead.ConversationID)) + } + lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format(time.DateTime))) + return strings.Join(lines, "\n") +} + +func salesLeadContactText(lead *models.SalesLead) string { + parts := make([]string, 0, 2) + if phone := strings.TrimSpace(lead.Phone); phone != "" { + parts = append(parts, phone) + } + if wechat := strings.TrimSpace(lead.WeChat); wechat != "" { + parts = append(parts, "微信 "+wechat) + } + if len(parts) == 0 { + return "暂无" + } + return strings.Join(parts, " / ") +} + +func salesLeadBudgetNotifyText(lead *models.SalesLead) string { + if lead.BudgetMin > 0 && lead.BudgetMax > 0 { + return fmt.Sprintf("%d-%d元", lead.BudgetMin, lead.BudgetMax) + } + if lead.BudgetMax > 0 { + return fmt.Sprintf("%d元左右", lead.BudgetMax) + } + if lead.BudgetMin > 0 { + return fmt.Sprintf("%d元以上", lead.BudgetMin) + } + return "-" +} + +func salesLeadAppointmentNotifyText(lead *models.SalesLead) string { + parts := []string{ + utils.FormatTimePtr(lead.AppointmentAt), + strings.TrimSpace(lead.AppointmentTimeText), + strings.TrimSpace(lead.AppointmentStore), + } + if lead.AppointmentPeople > 0 { + parts = append(parts, fmt.Sprintf("%d人", lead.AppointmentPeople)) + } + if remark := strings.TrimSpace(lead.AppointmentRemark); remark != "" { + parts = append(parts, remark) + } + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, " / ") +} + +func salesLeadIntentNotifyLabel(value enums.SalesLeadIntent) string { + switch value { + case enums.SalesLeadIntentHigh: + return "高意向" + case enums.SalesLeadIntentMedium: + return "中意向" + case enums.SalesLeadIntentLow: + return "低意向" + default: + return "未知" + } +} + +func salesLeadStageNotifyLabel(value enums.SalesLeadStage) string { + switch value { + case enums.SalesLeadStageConsulting: + return "咨询了解" + case enums.SalesLeadStageComparing: + return "对比决策" + case enums.SalesLeadStageAppointment: + return "预约到店" + case enums.SalesLeadStageReadyToBuy: + return "准备购买" + case enums.SalesLeadStageAfterSales: + return "售后问题" + default: + return "未知" + } +} diff --git a/internal/services/knowledge_faq_service.go b/internal/services/knowledge_faq_service.go index 76d8edb7..795c2352 100644 --- a/internal/services/knowledge_faq_service.go +++ b/internal/services/knowledge_faq_service.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "log/slog" + "strconv" + "strings" "time" "agent-desk/internal/ai/rag" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/utils" @@ -74,6 +77,161 @@ func (s *knowledgeFAQService) CreateKnowledgeFAQ(req request.CreateKnowledgeFAQR return s.Get(item.ID), nil } +func (s *knowledgeFAQService) CreateDraftFromRetrieveLog(req request.CreateKnowledgeFAQDraftFromRetrieveLogRequest, operator *dto.AuthPrincipal) (*models.KnowledgeFAQ, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + logItem := KnowledgeRetrieveLogService.Get(req.RetrieveLogID) + if logItem == nil { + return nil, errorsx.InvalidParamI18n("error.e0241") + } + if _, err := s.requireFAQKnowledgeBase(logItem.KnowledgeBaseID); err != nil { + return nil, err + } + question := strings.TrimSpace(logItem.Question) + if question == "" { + question = strings.TrimSpace(logItem.RewriteQuestion) + } + if question == "" { + return nil, errorsx.InvalidParamI18n("error.e0340") + } + + var existing models.KnowledgeFAQ + if err := sqls.DB(). + Where("knowledge_base_id = ? AND question = ? AND status <> ?", logItem.KnowledgeBaseID, question, enums.StatusDeleted). + Order("id desc"). + First(&existing).Error; err == nil { + return &existing, nil + } + + answer := strings.TrimSpace(req.Answer) + if answer == "" { + answer = strings.TrimSpace(logItem.Answer) + } + if answer == "" { + answer = "待补充标准答案" + } + similarQuestions := []string{} + rewriteQuestion := strings.TrimSpace(logItem.RewriteQuestion) + if rewriteQuestion != "" && rewriteQuestion != question { + similarQuestions = append(similarQuestions, rewriteQuestion) + } + similarQuestionsJSON, err := json.Marshal(similarQuestions) + if err != nil { + return nil, errorsx.InvalidParamI18n("error.e0280") + } + remark := strings.TrimSpace(req.Remark) + if remark == "" { + remark = "由知识检索日志生成的待确认 FAQ 草稿" + } + if req.RetrieveLogID > 0 { + remark = strings.TrimSpace(remark + "\n来源检索日志:" + strconv.FormatInt(req.RetrieveLogID, 10)) + } + + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: logItem.KnowledgeBaseID, + Question: question, + Answer: answer, + SimilarQuestions: string(similarQuestionsJSON), + Status: enums.StatusDisabled, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + IndexError: "", + IndexedAt: nil, + Remark: remark, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return s.Get(item.ID), nil +} + +func (s *knowledgeFAQService) BatchCreateDraftsFromRetrieveLogs(req request.BatchCreateKnowledgeFAQDraftsFromRetrieveLogsRequest, operator *dto.AuthPrincipal) (response.KnowledgeFAQDraftBatchCreateResponse, error) { + ret := response.KnowledgeFAQDraftBatchCreateResponse{ + DraftIDs: make([]int64, 0), + Skipped: make([]response.KnowledgeFAQDraftBatchSkipReason, 0), + } + if operator == nil { + return ret, errorsx.UnauthorizedI18n("error.auth.expired") + } + if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil { + return ret, err + } + limit := req.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + answerStatuses := req.AnswerStatuses + if len(answerStatuses) == 0 { + answerStatuses = []int{ + int(enums.KnowledgeAnswerStatusNoAnswer), + int(enums.KnowledgeAnswerStatusFallback), + int(enums.KnowledgeAnswerStatusBlocked), + } + } + + db := sqls.DB() + negativeFeedbackLogIDs := db.Model(&models.KnowledgeFeedback{}). + Select("retrieve_log_id"). + Where("feedback_type <> ?", int(enums.KnowledgeFeedbackTypeLike)) + query := db.Model(&models.KnowledgeRetrieveLog{}). + Where("knowledge_base_id = ?", req.KnowledgeBaseID). + Where("(question <> '' OR rewrite_question <> '')") + if req.IncludeNegativeFeedbacks { + query = query.Where("(answer_status IN ? OR id IN (?))", answerStatuses, negativeFeedbackLogIDs) + } else { + query = query.Where("answer_status IN ?", answerStatuses) + } + var logs []models.KnowledgeRetrieveLog + query.Order("created_at desc, id desc"). + Limit(limit). + Find(&logs) + ret.TotalCandidates = int64(len(logs)) + + for _, logItem := range logs { + question := strings.TrimSpace(logItem.Question) + if question == "" { + question = strings.TrimSpace(logItem.RewriteQuestion) + } + if question == "" { + ret.SkippedCount++ + ret.Skipped = append(ret.Skipped, response.KnowledgeFAQDraftBatchSkipReason{ + RetrieveLogID: logItem.ID, + Reason: "问题为空", + }) + continue + } + var existing models.KnowledgeFAQ + if err := db.Where("knowledge_base_id = ? AND question = ? AND status <> ?", logItem.KnowledgeBaseID, question, enums.StatusDeleted). + Order("id desc"). + First(&existing).Error; err == nil { + ret.ReusedCount++ + ret.DraftIDs = append(ret.DraftIDs, existing.ID) + continue + } + draft, err := s.CreateDraftFromRetrieveLog(request.CreateKnowledgeFAQDraftFromRetrieveLogRequest{ + RetrieveLogID: logItem.ID, + Answer: req.Answer, + Remark: req.Remark, + }, operator) + if err != nil { + ret.SkippedCount++ + ret.Skipped = append(ret.Skipped, response.KnowledgeFAQDraftBatchSkipReason{ + RetrieveLogID: logItem.ID, + Question: question, + Reason: err.Error(), + }) + continue + } + ret.CreatedCount++ + ret.DraftIDs = append(ret.DraftIDs, draft.ID) + } + return ret, nil +} + func (s *knowledgeFAQService) UpdateKnowledgeFAQ(req request.UpdateKnowledgeFAQRequest, operator *dto.AuthPrincipal) error { if operator == nil { return errorsx.UnauthorizedI18n("error.auth.expired") @@ -111,6 +269,37 @@ func (s *knowledgeFAQService) UpdateKnowledgeFAQ(req request.UpdateKnowledgeFAQR return rag.Index.IndexFAQByID(context.Background(), req.ID) } +func (s *knowledgeFAQService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(id) + if current == nil { + return errorsx.InvalidParamI18n("error.e0025") + } + if status != enums.StatusOk && status != enums.StatusDisabled { + return errorsx.InvalidParamI18n("error.e0254") + } + if current.Status == status { + return nil + } + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), id, map[string]any{ + "status": status, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + if status == enums.StatusDisabled { + return rag.Index.RemoveFAQIndex(context.Background(), id) + } + return rag.Index.IndexFAQByID(context.Background(), id) +} + func (s *knowledgeFAQService) DeleteKnowledgeFAQ(id int64) error { current := s.Get(id) if current == nil { diff --git a/internal/services/knowledge_retrieve_log_service.go b/internal/services/knowledge_retrieve_log_service.go index 5a92c1ed..edb42c2f 100644 --- a/internal/services/knowledge_retrieve_log_service.go +++ b/internal/services/knowledge_retrieve_log_service.go @@ -2,6 +2,12 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "strings" + "time" "agent-desk/internal/pkg/httpx/params" @@ -17,6 +23,20 @@ func newKnowledgeRetrieveLogService() *knowledgeRetrieveLogService { type knowledgeRetrieveLogService struct { } +type KnowledgeRetrieveLogFeedbackSummary struct { + FeedbackCount int64 + NegativeFeedbackCount int64 + LatestFeedbackType int + LatestFeedbackTypeName string + LatestFeedbackReason string +} + +const ( + KnowledgeRetrieveLogFeedbackStateNegative = "negative" + KnowledgeRetrieveLogFeedbackStateHasFeedback = "has_feedback" + KnowledgeRetrieveLogFeedbackStateNoFeedback = "no_feedback" +) + func (s *knowledgeRetrieveLogService) Get(id int64) *models.KnowledgeRetrieveLog { ret := &models.KnowledgeRetrieveLog{} if err := sqls.DB().First(ret, "id = ?", id).Error; err != nil { @@ -25,6 +45,21 @@ func (s *knowledgeRetrieveLogService) Get(id int64) *models.KnowledgeRetrieveLog return ret } +func (s *knowledgeRetrieveLogService) ApplyFeedbackStateFilter(cnd *sqls.Cnd, state string) *sqls.Cnd { + if cnd == nil { + cnd = sqls.NewCnd() + } + switch strings.TrimSpace(state) { + case KnowledgeRetrieveLogFeedbackStateNegative: + cnd.Where("EXISTS (SELECT 1 FROM knowledge_feedbacks WHERE knowledge_feedbacks.retrieve_log_id = knowledge_retrieve_logs.id AND knowledge_feedbacks.feedback_type <> ?)", int(enums.KnowledgeFeedbackTypeLike)) + case KnowledgeRetrieveLogFeedbackStateHasFeedback: + cnd.Where("EXISTS (SELECT 1 FROM knowledge_feedbacks WHERE knowledge_feedbacks.retrieve_log_id = knowledge_retrieve_logs.id)") + case KnowledgeRetrieveLogFeedbackStateNoFeedback: + cnd.Where("NOT EXISTS (SELECT 1 FROM knowledge_feedbacks WHERE knowledge_feedbacks.retrieve_log_id = knowledge_retrieve_logs.id)") + } + return cnd +} + func (s *knowledgeRetrieveLogService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeRetrieveLog, paging *sqls.Paging) { cnd := ¶ms.Cnd cnd.Find(sqls.DB(), &list) @@ -45,3 +80,77 @@ func (s *knowledgeRetrieveLogService) FindHitsByRetrieveLogID(retrieveLogID int6 sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("rank_no asc, id asc").Find(&list) return list } + +func (s *knowledgeRetrieveLogService) FindFeedbacksByRetrieveLogID(retrieveLogID int64) []models.KnowledgeFeedback { + if retrieveLogID <= 0 { + return nil + } + var list []models.KnowledgeFeedback + sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("id desc").Find(&list) + return list +} + +func (s *knowledgeRetrieveLogService) FindFeedbackSummariesByRetrieveLogIDs(retrieveLogIDs []int64) map[int64]KnowledgeRetrieveLogFeedbackSummary { + ret := make(map[int64]KnowledgeRetrieveLogFeedbackSummary, len(retrieveLogIDs)) + if len(retrieveLogIDs) == 0 { + return ret + } + + var feedbacks []models.KnowledgeFeedback + sqls.DB().Where("retrieve_log_id in ?", retrieveLogIDs).Order("id desc").Find(&feedbacks) + for _, item := range feedbacks { + summary := ret[item.RetrieveLogID] + summary.FeedbackCount++ + if item.FeedbackType != int(enums.KnowledgeFeedbackTypeLike) { + summary.NegativeFeedbackCount++ + } + if summary.LatestFeedbackType == 0 { + summary.LatestFeedbackType = item.FeedbackType + summary.LatestFeedbackTypeName = enums.GetKnowledgeFeedbackTypeLabel(enums.KnowledgeFeedbackType(item.FeedbackType)) + summary.LatestFeedbackReason = strings.TrimSpace(item.FeedbackReason) + if summary.LatestFeedbackReason == "" { + summary.LatestFeedbackReason = strings.TrimSpace(item.Remark) + } + } + ret[item.RetrieveLogID] = summary + } + return ret +} + +func (s *knowledgeRetrieveLogService) CreateFeedback(req request.CreateKnowledgeFeedbackRequest, operator *dto.AuthPrincipal) (*models.KnowledgeFeedback, error) { + if operator == nil { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if req.RetrieveLogID <= 0 || s.Get(req.RetrieveLogID) == nil { + return nil, errorsx.InvalidParam("retrieveLogId is invalid") + } + if !isValidKnowledgeFeedbackType(req.FeedbackType) { + return nil, errorsx.InvalidParam("feedbackType is invalid") + } + + item := &models.KnowledgeFeedback{ + RetrieveLogID: req.RetrieveLogID, + FeedbackType: req.FeedbackType, + FeedbackReason: strings.TrimSpace(req.FeedbackReason), + UserID: operator.UserID, + Remark: strings.TrimSpace(req.Remark), + CreatedAt: time.Now(), + } + if err := sqls.DB().Create(item).Error; err != nil { + return nil, err + } + return item, nil +} + +func isValidKnowledgeFeedbackType(feedbackType int) bool { + switch enums.KnowledgeFeedbackType(feedbackType) { + case enums.KnowledgeFeedbackTypeLike, + enums.KnowledgeFeedbackTypeDislike, + enums.KnowledgeFeedbackTypeNotHelpful, + enums.KnowledgeFeedbackTypeWrongCitation, + enums.KnowledgeFeedbackTypeOther: + return true + default: + return false + } +} diff --git a/internal/services/knowledge_retrieve_log_service_test.go b/internal/services/knowledge_retrieve_log_service_test.go new file mode 100644 index 00000000..5bd822ae --- /dev/null +++ b/internal/services/knowledge_retrieve_log_service_test.go @@ -0,0 +1,333 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestKnowledgeRetrieveLogServiceCreateFeedback(t *testing.T) { + setupKnowledgeRetrieveLogServiceTestDB(t) + logItem := createKnowledgeRetrieveLogServiceTestLog(t) + + feedback, err := KnowledgeRetrieveLogService.CreateFeedback(request.CreateKnowledgeFeedbackRequest{ + RetrieveLogID: logItem.ID, + FeedbackType: int(enums.KnowledgeFeedbackTypeWrongCitation), + FeedbackReason: " 引用内容不匹配 ", + Remark: " 需要补充门店售后政策 ", + }, &dto.AuthPrincipal{UserID: 42, Username: "quality"}) + if err != nil { + t.Fatalf("CreateFeedback() error = %v", err) + } + + if feedback.ID == 0 { + t.Fatal("feedback id was not assigned") + } + if feedback.UserID != 42 { + t.Fatalf("feedback.UserID = %d, want 42", feedback.UserID) + } + if feedback.FeedbackReason != "引用内容不匹配" { + t.Fatalf("feedback reason was not trimmed: %q", feedback.FeedbackReason) + } + if feedback.Remark != "需要补充门店售后政策" { + t.Fatalf("feedback remark was not trimmed: %q", feedback.Remark) + } + + list := KnowledgeRetrieveLogService.FindFeedbacksByRetrieveLogID(logItem.ID) + if len(list) != 1 || list[0].ID != feedback.ID { + t.Fatalf("FindFeedbacksByRetrieveLogID() = %+v, want created feedback", list) + } + + _, err = KnowledgeRetrieveLogService.CreateFeedback(request.CreateKnowledgeFeedbackRequest{ + RetrieveLogID: logItem.ID, + FeedbackType: int(enums.KnowledgeFeedbackTypeLike), + FeedbackReason: "后续回答清楚", + }, &dto.AuthPrincipal{UserID: 43, Username: "quality2"}) + if err != nil { + t.Fatalf("CreateFeedback() like error = %v", err) + } + + summary := KnowledgeRetrieveLogService.FindFeedbackSummariesByRetrieveLogIDs([]int64{logItem.ID})[logItem.ID] + if summary.FeedbackCount != 2 { + t.Fatalf("summary.FeedbackCount = %d, want 2", summary.FeedbackCount) + } + if summary.NegativeFeedbackCount != 1 { + t.Fatalf("summary.NegativeFeedbackCount = %d, want 1", summary.NegativeFeedbackCount) + } + if summary.LatestFeedbackType != int(enums.KnowledgeFeedbackTypeLike) { + t.Fatalf("summary.LatestFeedbackType = %d, want like", summary.LatestFeedbackType) + } + if summary.LatestFeedbackReason != "后续回答清楚" { + t.Fatalf("summary.LatestFeedbackReason = %q, want latest reason", summary.LatestFeedbackReason) + } +} + +func TestKnowledgeRetrieveLogServiceCreateFeedbackRejectsInvalidInput(t *testing.T) { + setupKnowledgeRetrieveLogServiceTestDB(t) + logItem := createKnowledgeRetrieveLogServiceTestLog(t) + operator := &dto.AuthPrincipal{UserID: 7, Username: "admin"} + + if _, err := KnowledgeRetrieveLogService.CreateFeedback(request.CreateKnowledgeFeedbackRequest{ + RetrieveLogID: logItem.ID, + FeedbackType: 999, + }, operator); err == nil { + t.Fatal("CreateFeedback() error is nil, want invalid feedback type error") + } + + if _, err := KnowledgeRetrieveLogService.CreateFeedback(request.CreateKnowledgeFeedbackRequest{ + RetrieveLogID: 99999, + FeedbackType: int(enums.KnowledgeFeedbackTypeLike), + }, operator); err == nil { + t.Fatal("CreateFeedback() error is nil, want invalid retrieve log id error") + } + + if _, err := KnowledgeRetrieveLogService.CreateFeedback(request.CreateKnowledgeFeedbackRequest{ + RetrieveLogID: logItem.ID, + FeedbackType: int(enums.KnowledgeFeedbackTypeLike), + }, nil); err == nil { + t.Fatal("CreateFeedback() error is nil, want permission error") + } +} + +func TestKnowledgeRetrieveLogFeedbackStateFilter(t *testing.T) { + setupKnowledgeRetrieveLogServiceTestDB(t) + logs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: 1, RequestID: "req-like", Question: "点赞问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: time.Now()}, + {KnowledgeBaseID: 1, RequestID: "req-negative", Question: "负反馈问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: time.Now()}, + {KnowledgeBaseID: 1, RequestID: "req-none", Question: "无反馈问题", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: time.Now()}, + } + if err := sqls.DB().Create(&logs).Error; err != nil { + t.Fatalf("create retrieve logs: %v", err) + } + feedbacks := []models.KnowledgeFeedback{ + {RetrieveLogID: logs[0].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeLike), FeedbackReason: "清楚", CreatedAt: time.Now()}, + {RetrieveLogID: logs[1].ID, FeedbackType: int(enums.KnowledgeFeedbackTypeWrongCitation), FeedbackReason: "引用不准", CreatedAt: time.Now()}, + } + if err := sqls.DB().Create(&feedbacks).Error; err != nil { + t.Fatalf("create feedbacks: %v", err) + } + + tests := []struct { + name string + state string + want []int64 + }{ + {name: "negative", state: KnowledgeRetrieveLogFeedbackStateNegative, want: []int64{logs[1].ID}}, + {name: "has_feedback", state: KnowledgeRetrieveLogFeedbackStateHasFeedback, want: []int64{logs[0].ID, logs[1].ID}}, + {name: "no_feedback", state: KnowledgeRetrieveLogFeedbackStateNoFeedback, want: []int64{logs[2].ID}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cnd := KnowledgeRetrieveLogService.ApplyFeedbackStateFilter(sqls.NewCnd().Asc("id"), tt.state) + var got []models.KnowledgeRetrieveLog + cnd.Find(sqls.DB(), &got) + if len(got) != len(tt.want) { + t.Fatalf("got %d logs, want %d: %+v", len(got), len(tt.want), got) + } + for i, item := range got { + if item.ID != tt.want[i] { + t.Fatalf("got id[%d]=%d, want %d", i, item.ID, tt.want[i]) + } + } + }) + } +} + +func TestKnowledgeFAQServiceCreateDraftFromRetrieveLog(t *testing.T) { + setupKnowledgeFAQDraftFromRetrieveLogTestDB(t) + kb := &models.KnowledgeBase{ + Name: "FAQ KB", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: enums.StatusOk, + } + if err := sqls.DB().Create(kb).Error; err != nil { + t.Fatalf("create knowledge base: %v", err) + } + logItem := &models.KnowledgeRetrieveLog{ + KnowledgeBaseID: kb.ID, + Question: "老人腰不好怎么选床垫?", + RewriteQuestion: "老人腰不好适合什么床垫?", + Answer: "建议优先看支撑分区款。", + AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), + CreatedAt: time.Now(), + } + if err := sqls.DB().Create(logItem).Error; err != nil { + t.Fatalf("create retrieve log: %v", err) + } + + draft, err := KnowledgeFAQService.CreateDraftFromRetrieveLog(request.CreateKnowledgeFAQDraftFromRetrieveLogRequest{ + RetrieveLogID: logItem.ID, + Remark: "负反馈后补充知识", + }, &dto.AuthPrincipal{UserID: 9, Username: "operator"}) + if err != nil { + t.Fatalf("CreateDraftFromRetrieveLog() error = %v", err) + } + if draft.ID == 0 { + t.Fatal("draft id was not assigned") + } + if draft.Status != enums.StatusDisabled { + t.Fatalf("draft.Status = %v, want disabled", draft.Status) + } + if draft.KnowledgeBaseID != kb.ID || draft.Question != logItem.Question || draft.Answer != logItem.Answer { + t.Fatalf("unexpected draft content: %#v", draft) + } + if draft.IndexStatus != enums.KnowledgeDocumentIndexStatusPending { + t.Fatalf("draft.IndexStatus = %s, want pending", draft.IndexStatus) + } + if draft.UpdateUserName != "operator" || draft.CreateUserName != "operator" { + t.Fatalf("draft audit fields not set: %#v", draft.AuditFields) + } + if draft.Remark == "" || !strings.Contains(draft.Remark, "来源检索日志") { + t.Fatalf("draft remark missing source log: %q", draft.Remark) + } + var similar []string + if err := json.Unmarshal([]byte(draft.SimilarQuestions), &similar); err != nil { + t.Fatalf("unmarshal similar questions: %v", err) + } + if len(similar) != 1 || similar[0] != logItem.RewriteQuestion { + t.Fatalf("unexpected similar questions: %#v", similar) + } + + reused, err := KnowledgeFAQService.CreateDraftFromRetrieveLog(request.CreateKnowledgeFAQDraftFromRetrieveLogRequest{ + RetrieveLogID: logItem.ID, + }, &dto.AuthPrincipal{UserID: 10, Username: "operator2"}) + if err != nil { + t.Fatalf("CreateDraftFromRetrieveLog() reuse error = %v", err) + } + if reused.ID != draft.ID { + t.Fatalf("expected existing draft to be reused, got %d want %d", reused.ID, draft.ID) + } +} + +func TestKnowledgeFAQServiceBatchCreateDraftsFromRetrieveLogs(t *testing.T) { + setupKnowledgeFAQDraftFromRetrieveLogTestDB(t) + kb := &models.KnowledgeBase{ + Name: "FAQ KB", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: enums.StatusOk, + } + if err := sqls.DB().Create(kb).Error; err != nil { + t.Fatalf("create knowledge base: %v", err) + } + now := time.Now() + logs := []models.KnowledgeRetrieveLog{ + {KnowledgeBaseID: kb.ID, Question: "周末活动能叠加吗?", Answer: "", AnswerStatus: int(enums.KnowledgeAnswerStatusFallback), CreatedAt: now.Add(3 * time.Minute)}, + {KnowledgeBaseID: kb.ID, Question: "老人腰不好怎么选?", Answer: "建议看分区支撑。", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: now.Add(2 * time.Minute)}, + {KnowledgeBaseID: kb.ID, Question: "旧问题已有 FAQ", Answer: "已有答案", AnswerStatus: int(enums.KnowledgeAnswerStatusNoAnswer), CreatedAt: now.Add(time.Minute)}, + {KnowledgeBaseID: kb.ID, Question: "正常回答不进候选", Answer: "正常答案", AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), CreatedAt: now}, + } + for i := range logs { + if err := sqls.DB().Create(&logs[i]).Error; err != nil { + t.Fatalf("create retrieve log: %v", err) + } + } + if err := sqls.DB().Create(&models.KnowledgeFeedback{ + RetrieveLogID: logs[1].ID, + FeedbackType: int(enums.KnowledgeFeedbackTypeDislike), + FeedbackReason: "不准确", + CreatedAt: now.Add(4 * time.Minute), + }).Error; err != nil { + t.Fatalf("create feedback: %v", err) + } + existing := models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "旧问题已有 FAQ", + Answer: "旧答案", + Status: enums.StatusDisabled, + } + if err := sqls.DB().Create(&existing).Error; err != nil { + t.Fatalf("create existing faq: %v", err) + } + + ret, err := KnowledgeFAQService.BatchCreateDraftsFromRetrieveLogs(request.BatchCreateKnowledgeFAQDraftsFromRetrieveLogsRequest{ + KnowledgeBaseID: kb.ID, + IncludeNegativeFeedbacks: true, + Limit: 20, + Remark: "批量生成待确认 FAQ 草稿", + }, &dto.AuthPrincipal{UserID: 12, Username: "quality"}) + if err != nil { + t.Fatalf("BatchCreateDraftsFromRetrieveLogs() error = %v", err) + } + if ret.TotalCandidates != 3 || ret.CreatedCount != 2 || ret.ReusedCount != 1 || ret.SkippedCount != 0 { + t.Fatalf("unexpected batch result: %#v", ret) + } + if len(ret.DraftIDs) != 3 { + t.Fatalf("unexpected draft ids: %#v", ret.DraftIDs) + } + var faqCount int64 + sqls.DB().Model(&models.KnowledgeFAQ{}). + Where("knowledge_base_id = ? AND status <> ?", kb.ID, enums.StatusDeleted). + Count(&faqCount) + if faqCount != 3 { + t.Fatalf("faq count = %d, want 3", faqCount) + } +} + +func TestKnowledgeFAQServiceUpdateStatusRejectsInvalidStatus(t *testing.T) { + setupKnowledgeFAQDraftFromRetrieveLogTestDB(t) + faq := &models.KnowledgeFAQ{ + KnowledgeBaseID: 1, + Question: "问题", + Answer: "答案", + Status: enums.StatusDisabled, + } + if err := sqls.DB().Create(faq).Error; err != nil { + t.Fatalf("create faq: %v", err) + } + + err := KnowledgeFAQService.UpdateStatus(faq.ID, enums.StatusDeleted, &dto.AuthPrincipal{UserID: 1, Username: "admin"}) + if err == nil { + t.Fatal("UpdateStatus() error is nil, want invalid status error") + } +} + +func setupKnowledgeRetrieveLogServiceTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeRetrieveLog{}, &models.KnowledgeFeedback{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func setupKnowledgeFAQDraftFromRetrieveLogTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeRetrieveLog{}, &models.KnowledgeFeedback{}, &models.KnowledgeFAQ{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeRetrieveLogServiceTestLog(t *testing.T) *models.KnowledgeRetrieveLog { + t.Helper() + item := &models.KnowledgeRetrieveLog{ + KnowledgeBaseID: 1, + Channel: string(enums.KnowledgeRetrieveChannelIM), + Scene: string(enums.KnowledgeRetrieveSceneQA), + RequestID: "req-feedback-test", + Question: "慕斯床垫怎么选?", + AnswerStatus: int(enums.KnowledgeAnswerStatusNormal), + CreatedAt: time.Now(), + } + if err := sqls.DB().Create(item).Error; err != nil { + t.Fatalf("create retrieve log: %v", err) + } + return item +} diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 94c9e7a0..92ce5ea6 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -543,6 +543,7 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, // 客户发送消息,触发AI回复 if senderType == enums.IMSenderTypeCustomer { + SalesLeadService.ExtractFromCustomerMessageAsync(*conversation, *message) if TriggerAIReplyAsyncHook != nil { TriggerAIReplyAsyncHook(*conversation, *message) } @@ -670,10 +671,11 @@ func (s *messageService) ValidateConversationSender(conversationID int64, sender if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") } - if conversation.Status != enums.IMConversationStatusAIServing && !s.allowAIMessageOnPendingHandoff(conversation) { + allowHandoffHold := s.allowAIMessageOnHandoffHold(conversation) + if conversation.Status != enums.IMConversationStatusAIServing && !allowHandoffHold { return nil, errorsx.ForbiddenI18n("error.e0189") } - if conversation.CurrentAssigneeID != 0 { + if conversation.CurrentAssigneeID != 0 && !allowHandoffHold { return nil, errorsx.ForbiddenI18n("error.e0192") } case enums.IMSenderTypeCustomer: @@ -687,12 +689,25 @@ func (s *messageService) ValidateConversationSender(conversationID int64, sender } func (s *messageService) allowAIMessageOnPendingHandoff(conversation *models.Conversation) bool { + return s.allowAIMessageOnHandoffHold(conversation) +} + +func (s *messageService) allowAIMessageOnHandoffHold(conversation *models.Conversation) bool { if conversation == nil { return false } - return conversation.Status == enums.IMConversationStatusPending && - conversation.HandoffAt != nil && - conversation.CurrentAssigneeID == 0 + if conversation.HandoffAt == nil { + return false + } + if conversation.Status != enums.IMConversationStatusPending && conversation.Status != enums.IMConversationStatusActive { + return false + } + humanReply := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversation.ID). + Where("sender_type = ?", enums.IMSenderTypeAgent). + Where("created_at >= ?", *conversation.HandoffAt). + Asc("id")) + return humanReply == nil } func (s *messageService) suffixFilenameForSummary(filename string) string { diff --git a/internal/services/product_service.go b/internal/services/product_service.go new file mode 100644 index 00000000..e4bd601f --- /dev/null +++ b/internal/services/product_service.go @@ -0,0 +1,828 @@ +package services + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "log/slog" + "strconv" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var ProductService = newProductService() + +func newProductService() *productService { + return &productService{} +} + +type productService struct { +} + +func (s *productService) Get(id int64) *models.Product { + return repositories.ProductRepository.Get(sqls.DB(), id) +} + +func (s *productService) List(req request.ProductListRequest) (list []models.Product, paging *sqls.Paging) { + tx := sqls.DB().Model(&models.Product{}).Where("status <> ?", enums.StatusDeleted) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + pat := "%" + kw + "%" + tx = tx.Where("name LIKE ? OR category LIKE ? OR selling_points LIKE ? OR suitable_people LIKE ? OR scenarios LIKE ? OR specs LIKE ?", pat, pat, pat, pat, pat, pat) + } + if category := strings.TrimSpace(req.Category); category != "" { + tx = tx.Where("category = ?", category) + } + if req.Status != nil { + tx = tx.Where("status = ?", *req.Status) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + slog.Error("product list count failed", "error", err) + } + if err := tx.Order("priority DESC, id DESC").Offset(req.Offset()).Limit(req.GetLimit()).Find(&list).Error; err != nil { + slog.Error("product list scan failed", "error", err) + } + return list, &sqls.Paging{Page: req.GetPage(), Limit: req.GetLimit(), Total: total} +} + +func (s *productService) Create(req request.SaveProductRequest, operator *dto.AuthPrincipal) (*models.Product, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + item, err := s.buildProductModel(req) + if err != nil { + return nil, err + } + item.AuditFields = utils.BuildAuditFields(operator) + if err := repositories.ProductRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + if err := s.SyncKnowledgeFAQ(item.ID); err != nil { + return item, err + } + return s.Get(item.ID), nil +} + +func (s *productService) Update(req request.SaveProductRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParam("product not found") + } + item, err := s.buildProductModel(req) + if err != nil { + return err + } + updates := map[string]any{ + "name": item.Name, + "category": item.Category, + "price_min": item.PriceMin, + "price_max": item.PriceMax, + "selling_points": item.SellingPoints, + "suitable_people": item.SuitablePeople, + "unsuitable_people": item.UnsuitablePeople, + "scenarios": item.Scenarios, + "specs": item.Specs, + "industry_attributes": item.IndustryAttributes, + "image_url": item.ImageURL, + "priority": item.Priority, + "knowledge_base_id": item.KnowledgeBaseID, + "status": item.Status, + "remark": item.Remark, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if err := repositories.ProductRepository.Updates(sqls.DB(), current.ID, updates); err != nil { + return err + } + return s.SyncKnowledgeFAQ(current.ID) +} + +func (s *productService) UpdateStatus(req request.UpdateProductStatusRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if !enums.IsValidStatus(req.Status) || enums.Status(req.Status) == enums.StatusDeleted { + return errorsx.InvalidParam("invalid product status") + } + if s.Get(req.ID) == nil { + return errorsx.InvalidParam("product not found") + } + if err := repositories.ProductRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "status": enums.Status(req.Status), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return s.SyncKnowledgeFAQ(req.ID) +} + +func (s *productService) Delete(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil { + return errorsx.InvalidParam("product not found") + } + if err := repositories.ProductRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.StatusDeleted, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + if item.KnowledgeFAQID > 0 { + if err := KnowledgeFAQService.DeleteKnowledgeFAQ(item.KnowledgeFAQID); err != nil { + slog.Error("failed to delete product knowledge faq", "productId", id, "faqId", item.KnowledgeFAQID, "error", err) + } + } + return nil +} + +func (s *productService) Reindex(id int64) error { + return s.SyncKnowledgeFAQ(id) +} + +func (s *productService) SeedMuseProducts(operator *dto.AuthPrincipal) error { + return s.SeedTemplateProducts("muse_bedding", operator) +} + +func (s *productService) SeedTemplateProducts(templateCode string, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + seeds, faqs, remark, err := productTemplateSeeds(templateCode) + if err != nil { + return err + } + if err := s.UpsertTemplateProducts(seeds, operator); err != nil { + return err + } + return s.seedGuideFAQs(faqs, remark, operator) +} + +func (s *productService) UpsertTemplateProducts(seeds []request.SaveProductRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + for _, req := range seeds { + existing := repositories.ProductRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", req.Name).Where("status <> ?", enums.StatusDeleted)) + if existing == nil { + if _, err := s.Create(req, operator); err != nil { + return err + } + continue + } + req.ID = existing.ID + if req.KnowledgeBaseID == 0 { + req.KnowledgeBaseID = existing.KnowledgeBaseID + } + if err := s.Update(req, operator); err != nil { + return err + } + } + return nil +} + +func (s *productService) ImportCSV(reader io.Reader, operator *dto.AuthPrincipal) (response.ProductImportResultResponse, error) { + ret := response.ProductImportResultResponse{Errors: make([]response.ProductImportRowResponse, 0)} + if operator == nil { + return ret, errorsx.UnauthorizedI18n("error.auth.expired") + } + rows, err := parseProductCSV(reader) + if err != nil { + return ret, err + } + for _, row := range rows { + ret.Total++ + req, err := buildProductImportRequest(row.Values) + if err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.ProductImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + existing := repositories.ProductRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", req.Name).Where("status <> ?", enums.StatusDeleted)) + if existing == nil { + if _, err := s.Create(req, operator); err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.ProductImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + ret.Created++ + continue + } + req.ID = existing.ID + if req.KnowledgeBaseID == 0 { + req.KnowledgeBaseID = existing.KnowledgeBaseID + } + if err := s.Update(req, operator); err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.ProductImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + ret.Updated++ + } + if ret.Total == 0 { + ret.Skipped = 0 + } + return ret, nil +} + +func (s *productService) SyncKnowledgeFAQ(productID int64) error { + item := s.Get(productID) + if item == nil { + return errorsx.InvalidParam("product not found") + } + kbID, err := s.resolveKnowledgeBaseID(item.KnowledgeBaseID) + if err != nil { + return err + } + question, answer, similarQuestions, remark := BuildProductKnowledgeFAQContent(item) + similarJSON, err := json.Marshal(similarQuestions) + if err != nil { + return err + } + now := time.Now() + faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), item.KnowledgeFAQID) + if faq == nil && item.KnowledgeFAQID > 0 { + item.KnowledgeFAQID = 0 + } + if faq == nil { + faq = &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + Question: question, + Answer: answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: item.Status, + Remark: remark, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: item.UpdateUserID, + CreateUserName: item.UpdateUserName, + UpdatedAt: now, + UpdateUserID: item.UpdateUserID, + UpdateUserName: item.UpdateUserName, + }, + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), faq); err != nil { + return err + } + if err := repositories.ProductRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "knowledge_base_id": kbID, + "knowledge_faq_id": faq.ID, + "updated_at": now, + }); err != nil { + return err + } + } else { + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faq.ID, map[string]any{ + "knowledge_base_id": kbID, + "question": question, + "answer": answer, + "similar_questions": string(similarJSON), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "status": item.Status, + "remark": remark, + "updated_at": now, + "update_user_id": item.UpdateUserID, + "update_user_name": item.UpdateUserName, + }); err != nil { + return err + } + if item.KnowledgeBaseID != kbID { + if err := repositories.ProductRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "knowledge_base_id": kbID, + "updated_at": now, + }); err != nil { + return err + } + } + } + if err := rag.Index.IndexFAQByID(context.Background(), faq.ID); err != nil { + return err + } + return nil +} + +func (s *productService) buildProductModel(req request.SaveProductRequest) (*models.Product, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("product name is required") + } + status := enums.Status(req.Status) + if req.Status == 0 { + status = enums.StatusOk + } + if !enums.IsValidStatus(int(status)) || status == enums.StatusDeleted { + return nil, errorsx.InvalidParam("invalid product status") + } + if req.PriceMin < 0 || req.PriceMax < 0 { + return nil, errorsx.InvalidParam("product price must be greater than or equal to 0") + } + if req.PriceMax > 0 && req.PriceMin > req.PriceMax { + return nil, errorsx.InvalidParam("product min price cannot exceed max price") + } + kbID := req.KnowledgeBaseID + if kbID > 0 { + if _, err := s.resolveKnowledgeBaseID(kbID); err != nil { + return nil, err + } + } + return &models.Product{ + Name: name, + Category: strings.TrimSpace(req.Category), + PriceMin: req.PriceMin, + PriceMax: req.PriceMax, + SellingPoints: strings.TrimSpace(req.SellingPoints), + SuitablePeople: strings.TrimSpace(req.SuitablePeople), + UnsuitablePeople: strings.TrimSpace(req.UnsuitablePeople), + Scenarios: strings.TrimSpace(req.Scenarios), + Specs: strings.TrimSpace(req.Specs), + IndustryAttributes: strings.TrimSpace(req.IndustryAttributes), + ImageURL: strings.TrimSpace(req.ImageURL), + Priority: req.Priority, + KnowledgeBaseID: kbID, + Status: status, + Remark: strings.TrimSpace(req.Remark), + }, nil +} + +type productCSVRow struct { + Row int + Values map[string]string +} + +func parseProductCSV(reader io.Reader) ([]productCSVRow, error) { + csvReader := csv.NewReader(reader) + csvReader.TrimLeadingSpace = true + csvReader.FieldsPerRecord = -1 + records, err := csvReader.ReadAll() + if err != nil { + return nil, errorsx.InvalidParam("invalid product csv file") + } + if len(records) == 0 { + return nil, errorsx.InvalidParam("product csv is empty") + } + headers := normalizeProductCSVHeaders(records[0]) + if len(headers) == 0 { + return nil, errorsx.InvalidParam("product csv header is empty") + } + rows := make([]productCSVRow, 0, len(records)-1) + for index, record := range records[1:] { + values := make(map[string]string, len(headers)) + hasValue := false + for i, header := range headers { + if header == "" || i >= len(record) { + continue + } + value := strings.TrimSpace(record[i]) + if value != "" { + hasValue = true + } + values[header] = value + } + if !hasValue { + continue + } + rows = append(rows, productCSVRow{Row: index + 2, Values: values}) + } + return rows, nil +} + +func normalizeProductCSVHeaders(raw []string) []string { + headers := make([]string, 0, len(raw)) + for _, item := range raw { + key := strings.TrimSpace(strings.TrimPrefix(item, "\ufeff")) + key = strings.ToLower(strings.ReplaceAll(key, " ", "")) + switch key { + case "产品名称", "名称", "name", "productname": + headers = append(headers, "name") + case "品类", "分类", "category": + headers = append(headers, "category") + case "最低价", "最低价格", "pricemin", "minprice": + headers = append(headers, "priceMin") + case "最高价", "最高价格", "pricemax", "maxprice": + headers = append(headers, "priceMax") + case "核心卖点", "卖点", "sellingpoints", "sellingpoint": + headers = append(headers, "sellingPoints") + case "适合人群", "suitablepeople": + headers = append(headers, "suitablePeople") + case "不适合人群", "unsuitablepeople": + headers = append(headers, "unsuitablePeople") + case "使用场景", "场景", "scenarios", "scenario": + headers = append(headers, "scenarios") + case "规格参数", "规格", "specs", "spec": + headers = append(headers, "specs") + case "行业属性", "扩展属性", "行业扩展属性", "industryattributes", "attributes": + headers = append(headers, "industryAttributes") + case "图片链接", "图片", "imageurl", "image": + headers = append(headers, "imageUrl") + case "推荐优先级", "优先级", "priority": + headers = append(headers, "priority") + case "知识库id", "知识库ID", "knowledgebaseid", "knowledgebase": + headers = append(headers, "knowledgeBaseId") + case "状态", "status": + headers = append(headers, "status") + case "备注", "remark": + headers = append(headers, "remark") + default: + headers = append(headers, "") + } + } + return headers +} + +func buildProductImportRequest(values map[string]string) (request.SaveProductRequest, error) { + req := request.SaveProductRequest{ + Name: strings.TrimSpace(values["name"]), + Category: strings.TrimSpace(values["category"]), + SellingPoints: strings.TrimSpace(values["sellingPoints"]), + SuitablePeople: strings.TrimSpace(values["suitablePeople"]), + UnsuitablePeople: strings.TrimSpace(values["unsuitablePeople"]), + Scenarios: strings.TrimSpace(values["scenarios"]), + Specs: strings.TrimSpace(values["specs"]), + IndustryAttributes: strings.TrimSpace(values["industryAttributes"]), + ImageURL: strings.TrimSpace(values["imageUrl"]), + Remark: strings.TrimSpace(values["remark"]), + Status: int(enums.StatusOk), + } + var err error + if req.PriceMin, err = parseProductImportInt64(values["priceMin"], "最低价"); err != nil { + return req, err + } + if req.PriceMax, err = parseProductImportInt64(values["priceMax"], "最高价"); err != nil { + return req, err + } + priority, err := parseProductImportInt(values["priority"], "推荐优先级") + if err != nil { + return req, err + } + req.Priority = priority + if req.KnowledgeBaseID, err = parseProductImportInt64(values["knowledgeBaseId"], "知识库ID"); err != nil { + return req, err + } + if status, ok, err := parseProductImportStatus(values["status"]); err != nil { + return req, err + } else if ok { + req.Status = int(status) + } + if strings.TrimSpace(req.Name) == "" { + return req, fmt.Errorf("产品名称不能为空") + } + return req, nil +} + +func parseProductImportInt(value string, field string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0, fmt.Errorf("%s必须是整数", field) + } + return parsed, nil +} + +func parseProductImportInt64(value string, field string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("%s必须是整数", field) + } + return parsed, nil +} + +func parseProductImportStatus(value string) (enums.Status, bool, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return enums.StatusOk, false, nil + } + switch value { + case "0", "启用", "上架", "ok", "enabled", "enable": + return enums.StatusOk, true, nil + case "1", "禁用", "下架", "disabled", "disable": + return enums.StatusDisabled, true, nil + default: + return enums.StatusOk, false, fmt.Errorf("状态只支持启用或禁用") + } +} + +func (s *productService) resolveKnowledgeBaseID(id int64) (int64, error) { + if id > 0 { + kb := repositories.KnowledgeBaseRepository.Get(sqls.DB(), id) + if kb == nil || kb.Status == enums.StatusDeleted || kb.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) { + return 0, errorsx.InvalidParam("usable FAQ knowledge base not found") + } + return id, nil + } + kb := repositories.KnowledgeBaseRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_type", string(enums.KnowledgeBaseTypeFAQ)).Where("status <> ?", enums.StatusDeleted).Asc("id")) + if kb == nil { + return 0, errorsx.InvalidParam("create a FAQ knowledge base before syncing products") + } + return kb.ID, nil +} + +func (s *productService) seedMuseGuideFAQs(operator *dto.AuthPrincipal) error { + return s.seedGuideFAQs(museGuideFAQSeeds(), "muse-guide-seed", operator) +} + +func (s *productService) seedGuideFAQs(items []guideFAQSeed, remark string, operator *dto.AuthPrincipal) error { + kbID, err := s.resolveKnowledgeBaseID(0) + if err != nil { + return err + } + for _, item := range items { + if err := s.upsertGuideFAQ(kbID, item.question, item.answer, item.similarQuestions, remark, operator); err != nil { + return err + } + } + return nil +} + +type guideFAQSeed struct { + question string + answer string + similarQuestions []string +} + +func (s *productService) upsertGuideFAQ(kbID int64, question string, answer string, similarQuestions []string, remark string, operator *dto.AuthPrincipal) error { + similarJSON, err := json.Marshal(similarQuestions) + if err != nil { + return err + } + now := time.Now() + existing := repositories.KnowledgeFAQRepository.FindByKnowledgeBaseIDAndQuestions(sqls.DB(), kbID, []string{question}) + if len(existing) == 0 { + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + Question: question, + Answer: answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: enums.StatusOk, + Remark: remark, + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), item.ID) + } + item := existing[0] + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "answer": answer, + "similar_questions": string(similarJSON), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "status": enums.StatusOk, + "remark": remark, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), item.ID) +} + +func BuildProductKnowledgeFAQContent(item *models.Product) (question string, answer string, similarQuestions []string, remark string) { + price := "未设置" + if item.PriceMin > 0 && item.PriceMax > 0 { + price = fmt.Sprintf("%d-%d元", item.PriceMin, item.PriceMax) + } else if item.PriceMin > 0 { + price = fmt.Sprintf("%d元起", item.PriceMin) + } else if item.PriceMax > 0 { + price = fmt.Sprintf("%d元左右", item.PriceMax) + } + category := strings.TrimSpace(item.Category) + if category == "" { + category = "产品" + } + lines := []string{ + fmt.Sprintf("产品名称:%s", item.Name), + fmt.Sprintf("品类:%s", category), + fmt.Sprintf("价格区间:%s", price), + } + appendField := func(label string, value string) { + if v := strings.TrimSpace(value); v != "" { + lines = append(lines, fmt.Sprintf("%s:%s", label, v)) + } + } + appendField("核心卖点", item.SellingPoints) + appendField("适合人群", item.SuitablePeople) + appendField("不适合人群", item.UnsuitablePeople) + appendField("使用场景", item.Scenarios) + appendField("规格参数", item.Specs) + appendField("行业扩展属性", item.IndustryAttributes) + lines = append(lines, "推荐话术:当客户预算、睡眠问题或使用场景与适合人群匹配时,可优先推荐该产品;如客户条件落入不适合人群,应主动说明并推荐其他款式。") + return "产品推荐:" + item.Name, + strings.Join(lines, "\n"), + []string{ + item.Name, + item.Name + "适合什么人", + item.Name + "价格", + category + "推荐", + "预算内怎么选" + category, + }, + "product:" + fmt.Sprint(item.ID) +} + +func museProductSeeds() []request.SaveProductRequest { + return []request.SaveProductRequest{ + {Name: "慕斯脊护支撑款", Category: "床垫", PriceMin: 12000, PriceMax: 18000, SellingPoints: "分区承托、偏硬支撑、护脊睡感,适合重视腰背支撑的家庭。", SuitablePeople: "腰背压力明显、喜欢偏硬睡感、老人或长期久坐人群。", UnsuitablePeople: "明确偏好很软包裹感的客户。", Scenarios: "主卧、老人房、改善腰背支撑。", Specs: "常见规格:1.5m、1.8m;可结合门店库存确认。", IndustryAttributes: "睡感:偏硬;支撑:分区承托;常问尺寸:1.5m/1.8m;到店体验:建议试躺确认软硬。", Priority: 90, Status: int(enums.StatusOk), Remark: "慕斯寝具模拟样板产品"}, + {Name: "慕斯云感舒睡款", Category: "床垫", PriceMin: 8000, PriceMax: 13000, SellingPoints: "柔和释压、包裹感好、适合日常舒适睡眠升级。", SuitablePeople: "年轻夫妻、侧睡较多、喜欢柔软贴合感的客户。", UnsuitablePeople: "强烈要求硬支撑或体重较大的客户。", Scenarios: "主卧、婚房、租房升级。", Specs: "常见规格:1.5m、1.8m。", IndustryAttributes: "睡感:偏软包裹;支撑:舒适释压;常问尺寸:1.5m/1.8m。", Priority: 80, Status: int(enums.StatusOk), Remark: "慕斯寝具模拟样板产品"}, + {Name: "慕斯儿童成长款", Category: "儿童床垫", PriceMin: 5000, PriceMax: 9000, SellingPoints: "软硬适中、支撑成长发育,面料亲肤。", SuitablePeople: "儿童房、青少年成长阶段、家长关注支撑和环保。", UnsuitablePeople: "成人大体重长期使用。", Scenarios: "儿童房、学生房。", Specs: "常见规格:1.2m、1.5m。", IndustryAttributes: "年龄段:儿童/青少年;睡感:软硬适中;关注点:成长支撑、亲肤面料。", Priority: 70, Status: int(enums.StatusOk), Remark: "慕斯寝具模拟样板产品"}, + {Name: "慕斯智能电动床", Category: "电动床", PriceMin: 16000, PriceMax: 28000, SellingPoints: "头脚升降、阅读观影模式、适合改善睡前休息体验。", SuitablePeople: "老人、孕妇、喜欢床上阅读观影、关注起身便利的人群。", UnsuitablePeople: "预算较低或只需要基础床架的客户。", Scenarios: "主卧、老人房、康养场景。", Specs: "建议与适配床垫组合选购。", IndustryAttributes: "功能:头脚升降、阅读观影模式;搭配:建议确认适配床垫;体验:到店演示更清楚。", Priority: 85, Status: int(enums.StatusOk), Remark: "慕斯寝具模拟样板产品"}, + } +} + +func productTemplateSeeds(templateCode string) ([]request.SaveProductRequest, []guideFAQSeed, string, error) { + switch strings.TrimSpace(templateCode) { + case "", "muse_bedding", "muse": + return museProductSeeds(), museGuideFAQSeeds(), "muse-guide-seed", nil + case "oral_clinic": + return oralClinicProductSeeds(), oralClinicGuideFAQSeeds(), "oral-clinic-guide-seed", nil + case "kids_english": + return kidsEnglishProductSeeds(), kidsEnglishGuideFAQSeeds(), "kids-english-guide-seed", nil + case "finance_advisor": + return financeAdvisorProductSeeds(), financeAdvisorGuideFAQSeeds(), "finance-advisor-guide-seed", nil + case "home_decoration": + return homeDecorationProductSeeds(), homeDecorationGuideFAQSeeds(), "home-decoration-guide-seed", nil + default: + return nil, nil, "", errorsx.InvalidParam("unsupported digital store template") + } +} + +func museGuideFAQSeeds() []guideFAQSeed { + return []guideFAQSeed{ + { + question: "主推床垫有哪些", + answer: strings.Join([]string{ + "慕斯寝具模拟门店当前主推:", + "1. 慕斯脊护支撑款:预算12000-18000元,分区承托、偏硬支撑、护脊睡感,适合老人、腰背压力明显、喜欢稳定支撑的客户。", + "2. 慕斯云感舒睡款:预算8000-13000元,柔和释压、包裹感好,适合年轻夫妻、侧睡较多、喜欢柔软贴合感的客户。", + "3. 慕斯儿童成长款:预算5000-9000元,软硬适中、支撑成长发育,适合儿童房和青少年成长阶段。", + "4. 慕斯智能电动床:预算16000-28000元,头脚升降、阅读观影模式,适合老人、孕妇、起身便利和康养场景。", + "导购原则:先确认预算、睡感偏好、身高体重、是否腰背不适、使用房间和到店试躺时间,再给出1-2个主推方案。", + }, "\n"), + similarQuestions: []string{"慕斯主推产品", "床垫推荐", "一万五预算床垫怎么选", "门店有哪些主推款"}, + }, + { + question: "如何根据人群推荐床垫", + answer: strings.Join([]string{ + "按人群推荐慕斯产品:", + "老人、腰不好、久坐人群:优先推荐慕斯脊护支撑款,预算12000-18000元,重点说明分区承托、偏硬支撑和到店试躺确认。", + "年轻夫妻、侧睡多、喜欢包裹感:优先推荐慕斯云感舒睡款,预算8000-13000元,重点说明柔和释压和舒适睡感。", + "儿童青少年:优先推荐慕斯儿童成长款,预算5000-9000元,重点说明软硬适中、成长支撑和儿童房使用。", + "老人房、孕妇、需要起身便利、阅读观影:可推荐慕斯智能电动床,预算16000-28000元,建议搭配适配床垫。", + "如果客户预算约15000且提到老人或腰背不适,首推慕斯脊护支撑款;如果客户愿意升级康养体验,再追加介绍慕斯智能电动床组合方案。", + }, "\n"), + similarQuestions: []string{"老人腰不好推荐哪款床垫", "腰背不适床垫推荐", "一万五老人床垫", "按人群怎么推荐慕斯床垫"}, + }, + } +} + +func oralClinicProductSeeds() []request.SaveProductRequest { + return []request.SaveProductRequest{ + {Name: "隐形矫正初诊评估", Category: "正畸服务", PriceMin: 0, PriceMax: 0, SellingPoints: "由正畸医生面诊评估牙列情况、拍片检查后给出矫正方案方向。", SuitablePeople: "牙齿拥挤、牙缝、龅牙、地包天、希望了解隐形矫正周期和预算的人群。", UnsuitablePeople: "急性口腔炎症未处理、需要先完成基础治疗的人群。", Scenarios: "正畸咨询、方案评估、预算了解。", Specs: "最终方案、周期和费用需以医生面诊和影像检查为准。", IndustryAttributes: "诊疗项目:正畸初诊;检查资料:口扫/拍片/医生面诊;费用周期:面诊后确认。", Priority: 95, Status: int(enums.StatusOk), Remark: "口腔门诊模拟样板服务"}, + {Name: "种植牙方案咨询", Category: "种植修复", PriceMin: 0, PriceMax: 0, SellingPoints: "医生根据缺牙位置、骨量、口腔健康状况评估种植修复可行性。", SuitablePeople: "单颗/多颗缺牙、活动假牙不适、希望了解种植牙方案的人群。", UnsuitablePeople: "严重全身疾病控制不佳或口腔炎症未处理者需先由医生评估。", Scenarios: "缺牙修复咨询、种植方案预约。", Specs: "费用、品牌和手术安排需面诊确认,不在线承诺效果。", IndustryAttributes: "诊疗项目:种植修复咨询;检查资料:口腔检查/影像评估;禁用口径:不承诺一定能种。", Priority: 90, Status: int(enums.StatusOk), Remark: "口腔门诊模拟样板服务"}, + {Name: "儿童涂氟与窝沟封闭", Category: "儿童齿科", PriceMin: 200, PriceMax: 800, SellingPoints: "帮助儿童建立预防龋齿管理习惯,医生检查后确认是否适合涂氟或窝沟封闭。", SuitablePeople: "3岁以上儿童、换牙期儿童、家长关注龋齿预防的人群。", UnsuitablePeople: "已出现明显疼痛或龋洞时需先检查治疗。", Scenarios: "儿童口腔检查、龋齿预防、家长咨询。", Specs: "具体项目和次数由儿童牙医检查后确认。", IndustryAttributes: "诊疗项目:儿童预防齿科;年龄:3岁以上更常见;是否适合:医生检查后确认。", Priority: 80, Status: int(enums.StatusOk), Remark: "口腔门诊模拟样板服务"}, + {Name: "舒适洁牙套餐", Category: "牙周护理", PriceMin: 300, PriceMax: 900, SellingPoints: "适合日常牙结石、牙渍清洁和牙周基础维护,洁牙前由医生检查口腔情况。", SuitablePeople: "半年到一年未洁牙、牙结石明显、口气困扰、备孕或正畸前检查人群。", UnsuitablePeople: "急性牙龈肿痛、严重牙周问题需医生先评估。", Scenarios: "洁牙预约、口腔基础护理。", Specs: "洁牙方式、是否需要牙周治疗需到店检查确认。", IndustryAttributes: "诊疗项目:洁牙/牙周基础护理;频次建议:需结合口腔情况;急症:先医生评估。", Priority: 75, Status: int(enums.StatusOk), Remark: "口腔门诊模拟样板服务"}, + } +} + +func oralClinicGuideFAQSeeds() []guideFAQSeed { + return []guideFAQSeed{ + { + question: "口腔门诊主推服务有哪些", + answer: strings.Join([]string{ + "口腔门诊模拟样板当前主推:", + "1. 隐形矫正初诊评估:适合牙齿拥挤、牙缝、龅牙、地包天或想了解矫正周期预算的客户,需医生面诊和影像检查后确认方案。", + "2. 种植牙方案咨询:适合缺牙、活动假牙不适或想了解种植修复的人群,费用和可行性需医生评估。", + "3. 儿童涂氟与窝沟封闭:适合关注儿童龋齿预防的家长,需儿童牙医检查后确认。", + "4. 舒适洁牙套餐:适合牙结石、牙渍、口气困扰或半年以上未洁牙的人群。", + "导购原则:先确认客户症状、年龄、期望、预算、就诊时间和联系方式,再引导预约初诊;不得承诺治疗效果、最低价或无需检查即可确定方案。", + }, "\n"), + similarQuestions: []string{"口腔门诊有哪些项目", "牙齿矫正怎么咨询", "种植牙怎么预约", "儿童涂氟适合吗", "洁牙多少钱"}, + }, + { + question: "口腔咨询如何合规回复", + answer: strings.Join([]string{ + "口腔咨询合规口径:", + "牙痛、牙龈出血、缺牙、牙齿不齐、儿童龋齿等问题,可以先做基础解释和就诊建议,但不能在线诊断。", + "涉及治疗方案、周期、费用、是否适合种植/矫正、是否需要拔牙,必须说明需要医生面诊、拍片或口腔检查后确认。", + "客户留下手机号或微信、明确要预约、询问医生时间或最终费用时,应引导留资并安排前台/顾问联系。", + "不得承诺无痛、包治好、一次解决、百分百成功、最低价、当天一定能做。", + }, "\n"), + similarQuestions: []string{"牙疼怎么办", "能不能保证矫正效果", "种植牙是不是一定能做", "洁牙会不会伤牙", "口腔咨询禁用承诺"}, + }, + } +} + +func kidsEnglishProductSeeds() []request.SaveProductRequest { + return []request.SaveProductRequest{ + {Name: "自然拼读进阶班", Category: "少儿英语", PriceMin: 3600, PriceMax: 6800, SellingPoints: "系统学习字母组合、拼读规则和高频词,帮助孩子提升自主阅读基础。", SuitablePeople: "小学低年级、能识别基础字母、想提升阅读启蒙的学生。", UnsuitablePeople: "零基础低龄儿童需先做入门测评。", Scenarios: "英语启蒙、阅读基础、寒暑假提升。", Specs: "常见班型:8-12人小班;课时和开班时间以校区确认为准。", IndustryAttributes: "年级:小学低年级;目标:自然拼读/阅读启蒙;班型:小班;试听:建议先测评。", Priority: 95, Status: int(enums.StatusOk), Remark: "教育培训模拟样板课程"}, + {Name: "剑桥少儿英语能力班", Category: "少儿英语", PriceMin: 6800, PriceMax: 12800, SellingPoints: "围绕听说读写和阶段测评训练,适合有一定基础的孩子持续提升。", SuitablePeople: "小学中高年级、希望系统提升听说读写和阶段测评能力的学生。", UnsuitablePeople: "只想短期保分或要求固定提分结果的客户。", Scenarios: "校内英语提升、能力测评、长期课程规划。", Specs: "最终班型、课时和学费需课程顾问结合测评确认。", IndustryAttributes: "年级:小学中高年级;目标:听说读写综合;禁用:不承诺保过提分。", Priority: 90, Status: int(enums.StatusOk), Remark: "教育培训模拟样板课程"}, + {Name: "一对一学习规划咨询", Category: "学习规划", PriceMin: 0, PriceMax: 0, SellingPoints: "课程顾问根据学生基础、目标和时间安排,给出试听与课程规划建议。", SuitablePeople: "目标不明确、需要先测评或家长想了解课程体系的客户。", UnsuitablePeople: "要求直接承诺升学、录取或固定分数结果的客户。", Scenarios: "入学测评、课程规划、试听预约。", Specs: "需留下学生年级、学习目标、联系电话和方便沟通时间。", IndustryAttributes: "咨询类型:测评/规划;留资字段:年级、目标、手机号、试听时间。", Priority: 85, Status: int(enums.StatusOk), Remark: "教育培训模拟样板服务"}, + } +} + +func kidsEnglishGuideFAQSeeds() []guideFAQSeed { + return []guideFAQSeed{ + { + question: "少儿英语课程怎么推荐", + answer: strings.Join([]string{ + "少儿英语样板课程推荐原则:", + "1. 小学低年级、想提升阅读启蒙:优先推荐自然拼读进阶班。", + "2. 小学中高年级、希望系统提升听说读写:推荐剑桥少儿英语能力班。", + "3. 目标不明确或基础不清楚:先推荐一对一学习规划咨询或试听测评。", + "导购时先问学生年级、英语基础、学习目标、可上课时间和家长联系方式;不得承诺保过、固定提分或录取结果。", + }, "\n"), + similarQuestions: []string{"孩子英语怎么选课", "自然拼读适合几年级", "英语试听怎么预约", "课程怎么推荐"}, + }, + { + question: "教育培训咨询有哪些禁用承诺", + answer: strings.Join([]string{ + "教育培训咨询禁用口径:", + "不得承诺保过、固定提分、包录取、证书包拿、名师一定授课、课程名额一定保留。", + "学费、退费、合同、老师资质、具体排课和考试政策都需要课程顾问人工确认。", + "客户留下手机号、学生年级、试听时间或询问最终学费时,应安排课程顾问跟进。", + }, "\n"), + similarQuestions: []string{"能不能保过", "能提高多少分", "退费政策", "老师资质", "学费多少"}, + }, + } +} + +func financeAdvisorProductSeeds() []request.SaveProductRequest { + return []request.SaveProductRequest{ + {Name: "经营贷资质初评", Category: "贷款咨询", PriceMin: 0, PriceMax: 0, SellingPoints: "根据企业经营年限、流水、资产和征信情况做基础资料清单说明。", SuitablePeople: "小微企业主、个体工商户、需要经营周转资金的客户。", UnsuitablePeople: "要求必批、最低利率或不愿做资质审核的客户。", Scenarios: "经营贷咨询、资料准备、顾问回访。", Specs: "额度、利率和审批结果需以持牌机构审核为准。", IndustryAttributes: "类型:贷款咨询;关键字段:城市、资金用途、企业情况;禁用:不承诺必批/额度/利率。", Priority: 95, Status: int(enums.StatusOk), Remark: "金融服务模拟样板"}, + {Name: "家庭保障方案咨询", Category: "保险咨询", PriceMin: 0, PriceMax: 0, SellingPoints: "围绕家庭成员、预算、保障缺口和缴费能力做基础保障方向说明。", SuitablePeople: "家庭保障规划、重疾/医疗/意外保障咨询客户。", UnsuitablePeople: "要求保证理赔、收益或绕过健康告知的客户。", Scenarios: "保险咨询、保障规划、顾问预约。", Specs: "具体产品、条款、费率和承保结论需持牌顾问确认。", IndustryAttributes: "类型:保险咨询;关键字段:家庭成员、预算、保障目标;禁用:不承诺理赔/收益。", Priority: 85, Status: int(enums.StatusOk), Remark: "金融服务模拟样板"}, + {Name: "资产配置风险测评预约", Category: "理财咨询", PriceMin: 0, PriceMax: 0, SellingPoints: "引导客户先完成风险偏好、资金期限和流动性需求确认。", SuitablePeople: "希望了解资产配置、现金管理或长期规划的客户。", UnsuitablePeople: "只追求保本高收益、拒绝风险测评的客户。", Scenarios: "理财咨询、风险测评、顾问沟通。", Specs: "收益、风险等级和适配方案需以合规测评和合同为准。", IndustryAttributes: "类型:理财咨询;关键字段:风险偏好、期限、资金用途;禁用:不承诺收益/保本。", Priority: 80, Status: int(enums.StatusOk), Remark: "金融服务模拟样板"}, + } +} + +func financeAdvisorGuideFAQSeeds() []guideFAQSeed { + return []guideFAQSeed{ + { + question: "金融咨询如何合规承接", + answer: strings.Join([]string{ + "金融服务样板咨询原则:", + "经营贷、保险和资产配置都只能做基础咨询和资料清单说明。", + "涉及额度、利率、收益、保本、合同条款、风险评级、承保或审批结果,必须转持牌顾问人工确认。", + "不得索要银行卡密码、验证码、完整证件影像等高敏信息;客户表达办理意向时引导留下手机号和方便沟通时间。", + }, "\n"), + similarQuestions: []string{"贷款能批多少", "利率最低多少", "理财保本吗", "保险能不能赔", "金融咨询怎么转人工"}, + }, + } +} + +func homeDecorationProductSeeds() []request.SaveProductRequest { + return []request.SaveProductRequest{ + {Name: "全案设计咨询", Category: "装修设计", PriceMin: 0, PriceMax: 0, SellingPoints: "根据户型面积、风格偏好、预算和居住需求,安排设计师初步沟通。", SuitablePeople: "准备装修、需要整体风格规划或空间改造的客户。", UnsuitablePeople: "要求不量房直接给最终报价的客户。", Scenarios: "装修咨询、设计预约、量房前沟通。", Specs: "最终方案和报价需量房、设计沟通和合同确认。", IndustryAttributes: "项目:全案设计;关键字段:面积、预算、风格、交房时间;禁用:不承诺最终价。", Priority: 95, Status: int(enums.StatusOk), Remark: "家装装修模拟样板服务"}, + {Name: "整装施工套餐咨询", Category: "整装施工", PriceMin: 80000, PriceMax: 300000, SellingPoints: "覆盖基础施工、主材选择和项目管理,适合希望省心整装的客户。", SuitablePeople: "新房装修、旧房翻新、希望设计施工一体化的客户。", UnsuitablePeople: "要求绝不增项、固定工期或未量房先签最终价的客户。", Scenarios: "整装咨询、报价初筛、设计师跟进。", Specs: "价格区间仅为样板范围,实际以量房、材料和合同为准。", IndustryAttributes: "项目:整装施工;关注:主材、工期、增项;禁用:绝不增项/固定工期。", Priority: 90, Status: int(enums.StatusOk), Remark: "家装装修模拟样板服务"}, + {Name: "旧房翻新评估", Category: "旧改翻新", PriceMin: 50000, PriceMax: 180000, SellingPoints: "关注拆改、水电、收纳和居住动线,需结合房龄与现场情况评估。", SuitablePeople: "老房翻新、局部改造、改善收纳和居住体验的客户。", UnsuitablePeople: "需要结构改动但不愿现场评估的客户。", Scenarios: "旧房改造、局部翻新、量房预约。", Specs: "拆改、工期和预算需设计师现场确认。", IndustryAttributes: "项目:旧房翻新;关键字段:房龄、面积、改造范围;风险:拆改和增项需人工确认。", Priority: 82, Status: int(enums.StatusOk), Remark: "家装装修模拟样板服务"}, + } +} + +func homeDecorationGuideFAQSeeds() []guideFAQSeed { + return []guideFAQSeed{ + { + question: "装修客户怎么推荐服务", + answer: strings.Join([]string{ + "家装装修样板推荐原则:", + "1. 还没明确方案:先推荐全案设计咨询,确认面积、预算、风格、交房时间。", + "2. 想省心整装:推荐整装施工套餐咨询,但说明最终报价需量房和合同确认。", + "3. 老房或局部改造:推荐旧房翻新评估,重点追问房龄、改造范围和是否可现场量房。", + "不得承诺一口价、绝不增项、固定工期、材料绝对环保或赔付金额。", + }, "\n"), + similarQuestions: []string{"装修怎么报价", "100平怎么装修", "旧房翻新多少钱", "能不能不增项", "预约量房"}, + }, + } +} diff --git a/internal/services/product_service_test.go b/internal/services/product_service_test.go new file mode 100644 index 00000000..65d5ede6 --- /dev/null +++ b/internal/services/product_service_test.go @@ -0,0 +1,133 @@ +package services + +import ( + "strings" + "testing" + + "agent-desk/internal/models" +) + +func TestBuildProductKnowledgeFAQContent(t *testing.T) { + product := &models.Product{ + ID: 42, + Name: "慕斯脊护支撑款", + Category: "床垫", + PriceMin: 12000, + PriceMax: 18000, + SellingPoints: "分区承托、偏硬支撑", + SuitablePeople: "腰背压力明显的人群", + Scenarios: "老人房", + Specs: "1.8m", + IndustryAttributes: "睡感:偏硬;支撑:分区承托", + } + + question, answer, similar, remark := BuildProductKnowledgeFAQContent(product) + if question != "产品推荐:慕斯脊护支撑款" { + t.Fatalf("unexpected question: %s", question) + } + for _, want := range []string{"价格区间:12000-18000元", "核心卖点:分区承托、偏硬支撑", "适合人群:腰背压力明显的人群", "行业扩展属性:睡感:偏硬", "推荐话术"} { + if !strings.Contains(answer, want) { + t.Fatalf("answer missing %q: %s", want, answer) + } + } + if len(similar) == 0 { + t.Fatal("similar questions should not be empty") + } + if remark != "product:42" { + t.Fatalf("unexpected remark: %s", remark) + } +} + +func TestParseProductCSVAndBuildImportRequest(t *testing.T) { + input := "\ufeff产品名称,品类,最低价,最高价,核心卖点,适合人群,行业属性,推荐优先级,状态\n" + + "慕斯脊护支撑款,床垫,12000,18000,分区承托,老人腰背压力明显,睡感:偏硬,90,启用\n" + + "\n" + rows, err := parseProductCSV(strings.NewReader(input)) + if err != nil { + t.Fatalf("parseProductCSV() error = %v", err) + } + if len(rows) != 1 || rows[0].Row != 2 { + t.Fatalf("unexpected rows: %#v", rows) + } + req, err := buildProductImportRequest(rows[0].Values) + if err != nil { + t.Fatalf("buildProductImportRequest() error = %v", err) + } + if req.Name != "慕斯脊护支撑款" || req.Category != "床垫" || req.PriceMin != 12000 || req.PriceMax != 18000 || req.Priority != 90 { + t.Fatalf("unexpected import request: %#v", req) + } + if req.IndustryAttributes != "睡感:偏硬" { + t.Fatalf("expected industry attributes parsed, got %#v", req) + } +} + +func TestBuildProductImportRequestRejectsInvalidNumber(t *testing.T) { + _, err := buildProductImportRequest(map[string]string{ + "name": "慕斯脊护支撑款", + "priceMin": "一万", + }) + if err == nil || !strings.Contains(err.Error(), "最低价必须是整数") { + t.Fatalf("expected invalid number error, got %v", err) + } +} + +func TestProductTemplateSeedsSupportsOralClinic(t *testing.T) { + seeds, faqs, remark, err := productTemplateSeeds("oral_clinic") + if err != nil { + t.Fatalf("productTemplateSeeds() error = %v", err) + } + if len(seeds) < 4 { + t.Fatalf("expected oral clinic product seeds, got %d", len(seeds)) + } + if len(faqs) == 0 { + t.Fatal("expected oral clinic FAQ seeds") + } + if remark != "oral-clinic-guide-seed" { + t.Fatalf("unexpected remark: %s", remark) + } + if seeds[0].Name != "隐形矫正初诊评估" { + t.Fatalf("unexpected first oral clinic seed: %#v", seeds[0]) + } + if !strings.Contains(seeds[0].IndustryAttributes, "诊疗项目") { + t.Fatalf("oral clinic seed should include industry attributes: %#v", seeds[0]) + } + if !strings.Contains(faqs[0].answer, "不得承诺治疗效果") { + t.Fatalf("oral clinic FAQ should include compliance boundary: %s", faqs[0].answer) + } +} + +func TestProductTemplateSeedsSupportIndustryMarketTemplates(t *testing.T) { + for _, tc := range []struct { + code string + firstName string + attribute string + faqBoundary string + remark string + }{ + {"kids_english", "自然拼读进阶班", "班型", "不得承诺保过", "kids-english-guide-seed"}, + {"finance_advisor", "经营贷资质初评", "禁用", "不得索要银行卡密码", "finance-advisor-guide-seed"}, + {"home_decoration", "全案设计咨询", "面积", "不得承诺一口价", "home-decoration-guide-seed"}, + } { + t.Run(tc.code, func(t *testing.T) { + seeds, faqs, remark, err := productTemplateSeeds(tc.code) + if err != nil { + t.Fatalf("productTemplateSeeds() error = %v", err) + } + if len(seeds) == 0 || len(faqs) == 0 { + t.Fatalf("expected product and FAQ seeds: products=%d faqs=%d", len(seeds), len(faqs)) + } + if seeds[0].Name != tc.firstName { + t.Fatalf("unexpected first product seed: %#v", seeds[0]) + } + if !strings.Contains(seeds[0].IndustryAttributes, tc.attribute) { + t.Fatalf("seed should include industry attribute %q: %#v", tc.attribute, seeds[0]) + } + if !strings.Contains(faqs[0].answer, tc.faqBoundary) && !strings.Contains(faqs[len(faqs)-1].answer, tc.faqBoundary) { + t.Fatalf("FAQ seeds should include boundary %q: %#v", tc.faqBoundary, faqs) + } + if remark != tc.remark { + t.Fatalf("unexpected remark: %s", remark) + } + }) + } +} diff --git a/internal/services/promotion_service.go b/internal/services/promotion_service.go new file mode 100644 index 00000000..8cd5a370 --- /dev/null +++ b/internal/services/promotion_service.go @@ -0,0 +1,819 @@ +package services + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var PromotionService = newPromotionService() + +func newPromotionService() *promotionService { + return &promotionService{} +} + +type promotionService struct { +} + +func (s *promotionService) Get(id int64) *models.Promotion { + return repositories.PromotionRepository.Get(sqls.DB(), id) +} + +func (s *promotionService) List(req request.PromotionListRequest) (list []models.Promotion, paging *sqls.Paging) { + tx := sqls.DB().Model(&models.Promotion{}).Where("status <> ?", enums.StatusDeleted) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + pat := "%" + kw + "%" + tx = tx.Where("name LIKE ? OR promotion_type LIKE ? OR description LIKE ? OR applicable_products LIKE ? OR discount_rule LIKE ? OR store_benefit LIKE ? OR appointment_benefit LIKE ?", pat, pat, pat, pat, pat, pat, pat) + } + if promotionType := strings.TrimSpace(req.PromotionType); promotionType != "" { + tx = tx.Where("promotion_type = ?", promotionType) + } + if req.Status != nil { + tx = tx.Where("status = ?", *req.Status) + } + if req.ActiveOnly { + now := time.Now() + tx = tx.Where("status = ?", enums.StatusOk). + Where("(start_at IS NULL OR start_at <= ?)", now). + Where("(end_at IS NULL OR end_at >= ?)", now) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, &sqls.Paging{Page: req.GetPage(), Limit: req.GetLimit(), Total: 0} + } + if err := tx.Order("priority DESC, id DESC").Offset(req.Offset()).Limit(req.GetLimit()).Find(&list).Error; err != nil { + return nil, &sqls.Paging{Page: req.GetPage(), Limit: req.GetLimit(), Total: total} + } + return list, &sqls.Paging{Page: req.GetPage(), Limit: req.GetLimit(), Total: total} +} + +func (s *promotionService) Create(req request.SavePromotionRequest, operator *dto.AuthPrincipal) (*models.Promotion, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + item, err := s.buildPromotionModel(req) + if err != nil { + return nil, err + } + item.AuditFields = utils.BuildAuditFields(operator) + if err := repositories.PromotionRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + if err := s.SyncKnowledgeFAQ(item.ID); err != nil { + return item, err + } + return s.Get(item.ID), nil +} + +func (s *promotionService) Update(req request.SavePromotionRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + current := s.Get(req.ID) + if current == nil { + return errorsx.InvalidParam("promotion not found") + } + item, err := s.buildPromotionModel(req) + if err != nil { + return err + } + if err := repositories.PromotionRepository.Updates(sqls.DB(), current.ID, map[string]any{ + "name": item.Name, + "promotion_type": item.PromotionType, + "description": item.Description, + "applicable_products": item.ApplicableProducts, + "start_at": item.StartAt, + "end_at": item.EndAt, + "discount_rule": item.DiscountRule, + "store_benefit": item.StoreBenefit, + "appointment_benefit": item.AppointmentBenefit, + "script_suggestion": item.ScriptSuggestion, + "priority": item.Priority, + "knowledge_base_id": item.KnowledgeBaseID, + "status": item.Status, + "remark": item.Remark, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return s.SyncKnowledgeFAQ(current.ID) +} + +func (s *promotionService) UpdateStatus(req request.UpdatePromotionStatusRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if !enums.IsValidStatus(req.Status) || enums.Status(req.Status) == enums.StatusDeleted { + return errorsx.InvalidParam("invalid promotion status") + } + if s.Get(req.ID) == nil { + return errorsx.InvalidParam("promotion not found") + } + if err := repositories.PromotionRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "status": enums.Status(req.Status), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return s.SyncKnowledgeFAQ(req.ID) +} + +func (s *promotionService) Delete(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := s.Get(id) + if item == nil { + return errorsx.InvalidParam("promotion not found") + } + if err := repositories.PromotionRepository.Updates(sqls.DB(), id, map[string]any{ + "status": enums.StatusDeleted, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + if item.KnowledgeFAQID > 0 { + if err := KnowledgeFAQService.DeleteKnowledgeFAQ(item.KnowledgeFAQID); err != nil { + return err + } + } + return nil +} + +func (s *promotionService) Reindex(id int64) error { + return s.SyncKnowledgeFAQ(id) +} + +func (s *promotionService) SeedMusePromotions(operator *dto.AuthPrincipal) error { + return s.SeedTemplatePromotions("muse_bedding", operator) +} + +func (s *promotionService) SeedTemplatePromotions(templateCode string, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + seeds, err := promotionTemplateSeeds(templateCode, time.Now()) + if err != nil { + return err + } + if err := s.UpsertTemplatePromotions(seeds, operator); err != nil { + return err + } + return s.syncCurrentPromotionGuideFAQ(operator) +} + +func (s *promotionService) UpsertTemplatePromotions(seeds []request.SavePromotionRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + for _, req := range seeds { + existing := repositories.PromotionRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", req.Name).Where("status <> ?", enums.StatusDeleted)) + if existing == nil { + if _, err := s.Create(req, operator); err != nil { + return err + } + continue + } + req.ID = existing.ID + if req.KnowledgeBaseID == 0 { + req.KnowledgeBaseID = existing.KnowledgeBaseID + } + if err := s.Update(req, operator); err != nil { + return err + } + } + return nil +} + +func (s *promotionService) ImportCSV(reader io.Reader, operator *dto.AuthPrincipal) (response.PromotionImportResultResponse, error) { + ret := response.PromotionImportResultResponse{Errors: make([]response.PromotionImportRowResponse, 0)} + if operator == nil { + return ret, errorsx.UnauthorizedI18n("error.auth.expired") + } + rows, err := parsePromotionCSV(reader) + if err != nil { + return ret, err + } + for _, row := range rows { + ret.Total++ + req, err := buildPromotionImportRequest(row.Values) + if err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.PromotionImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + existing := repositories.PromotionRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("name", req.Name).Where("status <> ?", enums.StatusDeleted)) + if existing == nil { + if _, err := s.Create(req, operator); err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.PromotionImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + ret.Created++ + continue + } + req.ID = existing.ID + if req.KnowledgeBaseID == 0 { + req.KnowledgeBaseID = existing.KnowledgeBaseID + } + if err := s.Update(req, operator); err != nil { + ret.Failed++ + ret.Errors = append(ret.Errors, response.PromotionImportRowResponse{Row: row.Row, Message: err.Error()}) + continue + } + ret.Updated++ + } + return ret, nil +} + +func (s *promotionService) SyncKnowledgeFAQ(promotionID int64) error { + item := s.Get(promotionID) + if item == nil { + return errorsx.InvalidParam("promotion not found") + } + kbID, err := resolveDigitalStoreKnowledgeBaseID(item.KnowledgeBaseID) + if err != nil { + return err + } + question, answer, similarQuestions, remark := BuildPromotionKnowledgeFAQContent(item) + similarJSON, err := json.Marshal(similarQuestions) + if err != nil { + return err + } + now := time.Now() + faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), item.KnowledgeFAQID) + if faq == nil && item.KnowledgeFAQID > 0 { + item.KnowledgeFAQID = 0 + } + if faq == nil { + faq = &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + Question: question, + Answer: answer, + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: item.Status, + Remark: remark, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: item.UpdateUserID, + CreateUserName: item.UpdateUserName, + UpdatedAt: now, + UpdateUserID: item.UpdateUserID, + UpdateUserName: item.UpdateUserName, + }, + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), faq); err != nil { + return err + } + if err := repositories.PromotionRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "knowledge_base_id": kbID, + "knowledge_faq_id": faq.ID, + "updated_at": now, + }); err != nil { + return err + } + } else { + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), faq.ID, map[string]any{ + "knowledge_base_id": kbID, + "question": question, + "answer": answer, + "similar_questions": string(similarJSON), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "status": item.Status, + "remark": remark, + "updated_at": now, + "update_user_id": item.UpdateUserID, + "update_user_name": item.UpdateUserName, + }); err != nil { + return err + } + if item.KnowledgeBaseID != kbID { + if err := repositories.PromotionRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "knowledge_base_id": kbID, + "updated_at": now, + }); err != nil { + return err + } + } + } + return rag.Index.IndexFAQByID(context.Background(), faq.ID) +} + +func (s *promotionService) buildPromotionModel(req request.SavePromotionRequest) (*models.Promotion, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("promotion name is required") + } + status := enums.Status(req.Status) + if req.Status == 0 { + status = enums.StatusOk + } + if !enums.IsValidStatus(int(status)) || status == enums.StatusDeleted { + return nil, errorsx.InvalidParam("invalid promotion status") + } + startAt, err := parsePromotionTime(req.StartAt) + if err != nil { + return nil, err + } + endAt, err := parsePromotionTime(req.EndAt) + if err != nil { + return nil, err + } + if startAt != nil && endAt != nil && startAt.After(*endAt) { + return nil, errorsx.InvalidParam("promotion start time cannot be after end time") + } + if req.KnowledgeBaseID > 0 { + if _, err := resolveDigitalStoreKnowledgeBaseID(req.KnowledgeBaseID); err != nil { + return nil, err + } + } + return &models.Promotion{ + Name: name, + PromotionType: strings.TrimSpace(req.PromotionType), + Description: strings.TrimSpace(req.Description), + ApplicableProducts: strings.TrimSpace(req.ApplicableProducts), + StartAt: startAt, + EndAt: endAt, + DiscountRule: strings.TrimSpace(req.DiscountRule), + StoreBenefit: strings.TrimSpace(req.StoreBenefit), + AppointmentBenefit: strings.TrimSpace(req.AppointmentBenefit), + ScriptSuggestion: strings.TrimSpace(req.ScriptSuggestion), + Priority: req.Priority, + KnowledgeBaseID: req.KnowledgeBaseID, + Status: status, + Remark: strings.TrimSpace(req.Remark), + }, nil +} + +func parsePromotionTime(raw string) (*time.Time, error) { + value := strings.TrimSpace(raw) + if value == "" { + return nil, nil + } + for _, layout := range []string{time.DateTime, time.DateOnly} { + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err == nil { + return &parsed, nil + } + } + return nil, errorsx.InvalidParam("invalid promotion time") +} + +type promotionCSVRow struct { + Row int + Values map[string]string +} + +func parsePromotionCSV(reader io.Reader) ([]promotionCSVRow, error) { + csvReader := csv.NewReader(reader) + csvReader.TrimLeadingSpace = true + csvReader.FieldsPerRecord = -1 + records, err := csvReader.ReadAll() + if err != nil { + return nil, errorsx.InvalidParam("invalid promotion csv file") + } + if len(records) == 0 { + return nil, errorsx.InvalidParam("promotion csv is empty") + } + headers := normalizePromotionCSVHeaders(records[0]) + if len(headers) == 0 { + return nil, errorsx.InvalidParam("promotion csv header is empty") + } + rows := make([]promotionCSVRow, 0, len(records)-1) + for index, record := range records[1:] { + values := make(map[string]string, len(headers)) + hasValue := false + for i, header := range headers { + if header == "" || i >= len(record) { + continue + } + value := strings.TrimSpace(record[i]) + if value != "" { + hasValue = true + } + values[header] = value + } + if !hasValue { + continue + } + rows = append(rows, promotionCSVRow{Row: index + 2, Values: values}) + } + return rows, nil +} + +func normalizePromotionCSVHeaders(raw []string) []string { + headers := make([]string, 0, len(raw)) + for _, item := range raw { + key := strings.TrimSpace(strings.TrimPrefix(item, "\ufeff")) + key = strings.ToLower(strings.ReplaceAll(key, " ", "")) + switch key { + case "活动名称", "名称", "name", "promotionname": + headers = append(headers, "name") + case "活动类型", "类型", "promotiontype", "type": + headers = append(headers, "promotionType") + case "活动描述", "活动说明", "描述", "description": + headers = append(headers, "description") + case "适用产品", "适用商品", "products", "applicableproducts": + headers = append(headers, "applicableProducts") + case "开始时间", "开始日期", "startat", "start": + headers = append(headers, "startAt") + case "结束时间", "结束日期", "endat", "end": + headers = append(headers, "endAt") + case "优惠规则", "discount", "discountrule": + headers = append(headers, "discountRule") + case "到店权益", "到店礼", "storebenefit": + headers = append(headers, "storeBenefit") + case "预约权益", "预约礼", "appointmentbenefit": + headers = append(headers, "appointmentBenefit") + case "话术建议", "推荐话术", "scriptsuggestion": + headers = append(headers, "scriptSuggestion") + case "推荐优先级", "优先级", "priority": + headers = append(headers, "priority") + case "知识库id", "知识库ID", "knowledgebaseid", "knowledgebase": + headers = append(headers, "knowledgeBaseId") + case "状态", "status": + headers = append(headers, "status") + case "备注", "remark": + headers = append(headers, "remark") + default: + headers = append(headers, "") + } + } + return headers +} + +func buildPromotionImportRequest(values map[string]string) (request.SavePromotionRequest, error) { + req := request.SavePromotionRequest{ + Name: strings.TrimSpace(values["name"]), + PromotionType: strings.TrimSpace(values["promotionType"]), + Description: strings.TrimSpace(values["description"]), + ApplicableProducts: strings.TrimSpace(values["applicableProducts"]), + DiscountRule: strings.TrimSpace(values["discountRule"]), + StoreBenefit: strings.TrimSpace(values["storeBenefit"]), + AppointmentBenefit: strings.TrimSpace(values["appointmentBenefit"]), + ScriptSuggestion: strings.TrimSpace(values["scriptSuggestion"]), + Remark: strings.TrimSpace(values["remark"]), + Status: int(enums.StatusOk), + } + startAt, err := parsePromotionImportDate(values["startAt"], "开始时间", false) + if err != nil { + return req, err + } + req.StartAt = startAt + endAt, err := parsePromotionImportDate(values["endAt"], "结束时间", true) + if err != nil { + return req, err + } + req.EndAt = endAt + if req.Priority, err = parsePromotionImportInt(values["priority"], "推荐优先级"); err != nil { + return req, err + } + if req.KnowledgeBaseID, err = parsePromotionImportInt64(values["knowledgeBaseId"], "知识库ID"); err != nil { + return req, err + } + if status, ok, err := parsePromotionImportStatus(values["status"]); err != nil { + return req, err + } else if ok { + req.Status = int(status) + } + if req.Name == "" { + return req, fmt.Errorf("活动名称不能为空") + } + return req, nil +} + +func parsePromotionImportDate(value string, field string, endOfDay bool) (string, error) { + value = strings.TrimSpace(strings.ReplaceAll(value, "/", "-")) + if value == "" { + return "", nil + } + for _, layout := range []string{time.DateTime, "2006-01-02 15:04", time.DateOnly} { + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err != nil { + continue + } + if layout == time.DateOnly && endOfDay { + parsed = parsed.Add(24*time.Hour - time.Second) + } + return parsed.Format(time.DateTime), nil + } + return "", fmt.Errorf("%s格式需为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss", field) +} + +func parsePromotionImportInt(value string, field string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0, fmt.Errorf("%s必须是整数", field) + } + return parsed, nil +} + +func parsePromotionImportInt64(value string, field string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("%s必须是整数", field) + } + return parsed, nil +} + +func parsePromotionImportStatus(value string) (enums.Status, bool, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return enums.StatusOk, false, nil + } + switch value { + case "0", "启用", "上架", "ok", "enabled", "enable": + return enums.StatusOk, true, nil + case "1", "禁用", "下架", "disabled", "disable": + return enums.StatusDisabled, true, nil + default: + return enums.StatusOk, false, fmt.Errorf("状态只支持启用或禁用") + } +} + +func BuildPromotionKnowledgeFAQContent(item *models.Promotion) (question string, answer string, similarQuestions []string, remark string) { + timeRange := "长期有效" + if item.StartAt != nil && item.EndAt != nil { + timeRange = fmt.Sprintf("%s 至 %s", item.StartAt.Format(time.DateOnly), item.EndAt.Format(time.DateOnly)) + } else if item.StartAt != nil { + timeRange = item.StartAt.Format(time.DateOnly) + " 起" + } else if item.EndAt != nil { + timeRange = item.EndAt.Format(time.DateOnly) + " 前有效" + } + promotionType := strings.TrimSpace(item.PromotionType) + if promotionType == "" { + promotionType = "促销活动" + } + lines := []string{ + "活动名称:" + item.Name, + "活动类型:" + promotionType, + "有效期:" + timeRange, + } + appendField := func(label string, value string) { + if v := strings.TrimSpace(value); v != "" { + lines = append(lines, label+":"+v) + } + } + appendField("活动说明", item.Description) + appendField("适用产品", item.ApplicableProducts) + appendField("优惠规则", item.DiscountRule) + appendField("到店权益", item.StoreBenefit) + appendField("预约权益", item.AppointmentBenefit) + appendField("推荐话术", item.ScriptSuggestion) + lines = append(lines, "导购要求:仅在活动启用且处于有效期时主动推荐;如果客户询问最终成交价、库存或叠加优惠,应引导留资或转人工确认。") + return "活动优惠:" + item.Name, + strings.Join(lines, "\n"), + []string{ + item.Name, + "现在有什么优惠", + "到店有什么权益", + "预约试躺有什么礼品", + "活动怎么参加", + promotionType + "活动", + }, + "promotion:" + fmt.Sprint(item.ID) +} + +func (s *promotionService) syncCurrentPromotionGuideFAQ(operator *dto.AuthPrincipal) error { + kbID, err := resolveDigitalStoreKnowledgeBaseID(0) + if err != nil { + return err + } + now := time.Now() + list, _ := s.List(request.PromotionListRequest{Page: 1, Limit: 20, ActiveOnly: true}) + lines := []string{"当前可主动推荐的活动优惠:"} + if len(list) == 0 { + lines = append(lines, "暂无启用且在有效期内的活动。") + } + for i, item := range list { + _, answer, _, _ := BuildPromotionKnowledgeFAQContent(&item) + lines = append(lines, fmt.Sprintf("%d. %s\n%s", i+1, item.Name, answer)) + } + similarJSON, err := json.Marshal([]string{"当前活动", "现在有什么优惠", "预约权益", "到店权益", "近期促销"}) + if err != nil { + return err + } + const question = "当前活动优惠总览" + existing := repositories.KnowledgeFAQRepository.FindByKnowledgeBaseIDAndQuestions(sqls.DB(), kbID, []string{question}) + if len(existing) == 0 { + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: kbID, + Question: question, + Answer: strings.Join(lines, "\n\n"), + SimilarQuestions: string(similarJSON), + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Status: enums.StatusOk, + Remark: "promotion-guide-seed", + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), item.ID) + } + item := existing[0] + if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), item.ID, map[string]any{ + "answer": strings.Join(lines, "\n\n"), + "similar_questions": string(similarJSON), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "status": enums.StatusOk, + "remark": "promotion-guide-seed", + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return err + } + return rag.Index.IndexFAQByID(context.Background(), item.ID) +} + +func musePromotionSeeds(now time.Time) []request.SavePromotionRequest { + start := now.AddDate(0, 0, -7).Format(time.DateOnly) + end := now.AddDate(0, 1, 0).Format(time.DateOnly) + return []request.SavePromotionRequest{ + { + Name: "周末预约试躺礼", + PromotionType: "预约权益", + Description: "客户提前预约周末到店试躺,可安排睡眠顾问预留体验时段。", + ApplicableProducts: "慕斯脊护支撑款、慕斯云感舒睡款、慕斯智能电动床", + StartAt: start, + EndAt: end, + DiscountRule: "具体成交价和叠加优惠以门店顾问确认为准。", + StoreBenefit: "到店可享免费睡眠咨询和床垫软硬度试躺对比。", + AppointmentBenefit: "提前预约并留下手机号,可预留周末体验时段;到店可领取护睡礼包一份,数量以门店为准。", + ScriptSuggestion: "如果客户提到周末、到店、试躺或老人选床垫,优先邀请预约试躺并留下姓名、手机号、到店时间和预算。", + Priority: 90, + Status: int(enums.StatusOk), + Remark: "慕斯寝具模拟活动", + }, + { + Name: "智能电动床组合体验季", + PromotionType: "组合权益", + Description: "智能电动床搭配适配床垫的体验活动,适合老人房、孕妇、阅读观影和康养场景。", + ApplicableProducts: "慕斯智能电动床、慕斯脊护支撑款", + StartAt: start, + EndAt: end, + DiscountRule: "组合方案可到店咨询顾问确认预算和适配规格。", + StoreBenefit: "到店可体验头脚升降、阅读观影模式和床垫适配方案。", + AppointmentBenefit: "预约体验可优先安排电动床演示和顾问一对一讲解。", + ScriptSuggestion: "客户提到老人起身、孕妇、床上阅读观影或康养需求时,可补充介绍智能电动床组合体验。", + Priority: 80, + Status: int(enums.StatusOk), + Remark: "慕斯寝具模拟活动", + }, + } +} + +func promotionTemplateSeeds(templateCode string, now time.Time) ([]request.SavePromotionRequest, error) { + switch strings.TrimSpace(templateCode) { + case "", "muse_bedding", "muse": + return musePromotionSeeds(now), nil + case "oral_clinic": + return oralClinicPromotionSeeds(now), nil + case "kids_english": + return kidsEnglishPromotionSeeds(now), nil + case "finance_advisor": + return financeAdvisorPromotionSeeds(now), nil + case "home_decoration": + return homeDecorationPromotionSeeds(now), nil + default: + return nil, errorsx.InvalidParam("unsupported digital store template") + } +} + +func oralClinicPromotionSeeds(now time.Time) []request.SavePromotionRequest { + start := now.AddDate(0, 0, -7).Format(time.DateOnly) + end := now.AddDate(0, 1, 0).Format(time.DateOnly) + return []request.SavePromotionRequest{ + { + Name: "正畸初诊评估预约礼", + PromotionType: "预约权益", + Description: "客户预约隐形矫正初诊评估,可优先安排正畸医生评估时段。", + ApplicableProducts: "隐形矫正初诊评估", + StartAt: start, + EndAt: end, + DiscountRule: "初诊检查项目、影像检查和最终费用以门诊确认及医生评估为准。", + StoreBenefit: "到店可由顾问协助完成基础资料登记,并安排医生评估牙列情况。", + AppointmentBenefit: "提前预约并留下手机号、期望时间和主要诉求,可优先匹配正畸咨询时段。", + ScriptSuggestion: "客户提到牙齿不齐、牙缝、龅牙、地包天或想了解矫正预算时,先声明需医生面诊确认,再引导预约初诊并留联系方式。", + Priority: 90, + Status: int(enums.StatusOk), + Remark: "口腔门诊模拟活动", + }, + { + Name: "儿童口腔检查关爱周", + PromotionType: "儿童齿科", + Description: "面向关注儿童龋齿预防的家长,提供儿童口腔检查和预防项目咨询。", + ApplicableProducts: "儿童涂氟与窝沟封闭", + StartAt: start, + EndAt: end, + DiscountRule: "儿童涂氟、窝沟封闭是否适合及具体费用需儿童牙医检查后确认。", + StoreBenefit: "到店可了解儿童龋齿预防建议和定期检查安排。", + AppointmentBenefit: "预约可优先安排儿童齿科时段,建议留下儿童年龄、是否首次就诊和期望日期。", + ScriptSuggestion: "客户询问孩子蛀牙预防、涂氟、窝沟封闭或换牙期问题时,提醒不能在线诊断,并引导家长预约儿童牙医检查。", + Priority: 80, + Status: int(enums.StatusOk), + Remark: "口腔门诊模拟活动", + }, + } +} + +func kidsEnglishPromotionSeeds(now time.Time) []request.SavePromotionRequest { + start := now.AddDate(0, 0, -7).Format(time.DateOnly) + end := now.AddDate(0, 1, 0).Format(time.DateOnly) + return []request.SavePromotionRequest{ + { + Name: "少儿英语试听测评预约礼", + PromotionType: "试听权益", + Description: "客户预约试听或测评,可优先安排课程顾问做年级、基础和目标沟通。", + ApplicableProducts: "自然拼读进阶班,剑桥少儿英语能力班,一对一学习规划咨询", + StartAt: start, + EndAt: end, + DiscountRule: "试听课、测评方式、班型名额和最终学费以校区课程顾问确认为准。", + StoreBenefit: "到校可了解课程体系、班型安排和阶段学习建议。", + AppointmentBenefit: "提前预约并留下学生年级、学习目标、手机号和试听时间,可优先匹配顾问时段。", + ScriptSuggestion: "客户提到孩子年级、英语基础、试听或学费时,先追问目标与时间,再引导预约试听测评。", + Priority: 90, + Status: int(enums.StatusOk), + Remark: "教育培训模拟活动", + }, + } +} + +func financeAdvisorPromotionSeeds(now time.Time) []request.SavePromotionRequest { + start := now.AddDate(0, 0, -7).Format(time.DateOnly) + end := now.AddDate(0, 1, 0).Format(time.DateOnly) + return []request.SavePromotionRequest{ + { + Name: "金融顾问合规初评预约", + PromotionType: "咨询预约", + Description: "客户预约后由顾问进行基础需求沟通和资料清单说明。", + ApplicableProducts: "经营贷资质初评,家庭保障方案咨询,资产配置风险测评预约", + StartAt: start, + EndAt: end, + DiscountRule: "咨询不代表审批、承保或收益承诺;具体方案、费率、合同和风险等级以持牌顾问确认为准。", + StoreBenefit: "顾问可协助梳理基础资料、风险提示和下一步沟通方式。", + AppointmentBenefit: "提前预约并留下姓名、手机号、所在城市和咨询方向,可优先安排顾问回访。", + ScriptSuggestion: "客户询问利率、额度、收益或保险方案时,先做合规边界说明,再引导留下联系方式由持牌顾问确认。", + Priority: 90, + Status: int(enums.StatusOk), + Remark: "金融服务模拟活动", + }, + } +} + +func homeDecorationPromotionSeeds(now time.Time) []request.SavePromotionRequest { + start := now.AddDate(0, 0, -7).Format(time.DateOnly) + end := now.AddDate(0, 1, 0).Format(time.DateOnly) + return []request.SavePromotionRequest{ + { + Name: "免费量房与设计咨询季", + PromotionType: "量房预约", + Description: "预约量房后由设计师了解户型、面积、预算、风格和装修阶段。", + ApplicableProducts: "全案设计咨询,整装施工套餐咨询,旧房翻新评估", + StartAt: start, + EndAt: end, + DiscountRule: "最终报价、材料、工期、增项和合同权益需量房与设计沟通后确认。", + StoreBenefit: "到店可了解设计案例、材料展厅和施工流程。", + AppointmentBenefit: "提前预约并留下小区/面积/预算/风格/手机号,可优先安排设计师量房沟通。", + ScriptSuggestion: "客户提到面积、预算、风格、交房或旧房翻新时,先追问量房条件,再引导预约设计师。", + Priority: 90, + Status: int(enums.StatusOk), + Remark: "家装装修模拟活动", + }, + } +} diff --git a/internal/services/promotion_service_test.go b/internal/services/promotion_service_test.go new file mode 100644 index 00000000..a35643df --- /dev/null +++ b/internal/services/promotion_service_test.go @@ -0,0 +1,126 @@ +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" +) + +func TestBuildPromotionKnowledgeFAQContent(t *testing.T) { + start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.Local) + end := time.Date(2026, 7, 31, 0, 0, 0, 0, time.Local) + promotion := &models.Promotion{ + ID: 7, + Name: "周末预约试躺礼", + PromotionType: "预约权益", + Description: "提前预约周末试躺", + ApplicableProducts: "慕斯脊护支撑款", + StartAt: &start, + EndAt: &end, + DiscountRule: "成交价到店确认", + StoreBenefit: "免费睡眠咨询", + AppointmentBenefit: "护睡礼包", + ScriptSuggestion: "引导客户留下手机号预约", + } + + question, answer, similar, remark := BuildPromotionKnowledgeFAQContent(promotion) + if question != "活动优惠:周末预约试躺礼" { + t.Fatalf("unexpected question: %s", question) + } + for _, want := range []string{"活动类型:预约权益", "有效期:2026-07-01 至 2026-07-31", "预约权益:护睡礼包", "推荐话术:引导客户留下手机号预约"} { + if !strings.Contains(answer, want) { + t.Fatalf("answer missing %q: %s", want, answer) + } + } + if len(similar) == 0 { + t.Fatal("similar questions should not be empty") + } + if remark != "promotion:7" { + t.Fatalf("unexpected remark: %s", remark) + } +} + +func TestParsePromotionCSVAndBuildImportRequest(t *testing.T) { + input := "\ufeff活动名称,活动类型,活动描述,适用产品,开始时间,结束时间,优惠规则,到店权益,预约权益,话术建议,推荐优先级,状态\n" + + "周末预约试躺礼,预约权益,提前预约周末试躺,慕斯脊护支撑款,2026-07-01,2026-07-31,成交价到店确认,免费睡眠咨询,护睡礼包,引导客户留下手机号预约,90,启用\n" + + "\n" + rows, err := parsePromotionCSV(strings.NewReader(input)) + if err != nil { + t.Fatalf("parsePromotionCSV() error = %v", err) + } + if len(rows) != 1 || rows[0].Row != 2 { + t.Fatalf("unexpected rows: %#v", rows) + } + req, err := buildPromotionImportRequest(rows[0].Values) + if err != nil { + t.Fatalf("buildPromotionImportRequest() error = %v", err) + } + if req.Name != "周末预约试躺礼" || req.PromotionType != "预约权益" || req.Priority != 90 || req.Status != 0 { + t.Fatalf("unexpected import request: %#v", req) + } + if req.StartAt != "2026-07-01 00:00:00" || req.EndAt != "2026-07-31 23:59:59" { + t.Fatalf("unexpected date range: %s - %s", req.StartAt, req.EndAt) + } +} + +func TestBuildPromotionImportRequestRejectsInvalidDate(t *testing.T) { + _, err := buildPromotionImportRequest(map[string]string{ + "name": "周末预约试躺礼", + "startAt": "2026年7月1日", + }) + if err == nil || !strings.Contains(err.Error(), "开始时间格式需为") { + t.Fatalf("expected invalid date error, got %v", err) + } +} + +func TestPromotionTemplateSeedsSupportsOralClinic(t *testing.T) { + seeds, err := promotionTemplateSeeds("oral_clinic", time.Date(2026, 7, 1, 12, 0, 0, 0, time.Local)) + if err != nil { + t.Fatalf("promotionTemplateSeeds() error = %v", err) + } + if len(seeds) != 2 { + t.Fatalf("expected 2 oral clinic promotion seeds, got %d", len(seeds)) + } + if seeds[0].Name != "正畸初诊评估预约礼" { + t.Fatalf("unexpected first oral clinic promotion: %#v", seeds[0]) + } + if !strings.Contains(seeds[0].DiscountRule, "医生评估") { + t.Fatalf("oral clinic promotion should keep medical confirmation boundary: %s", seeds[0].DiscountRule) + } +} + +func TestPromotionTemplateSeedsSupportIndustryMarketTemplates(t *testing.T) { + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.Local) + for _, tc := range []struct { + code string + name string + typeName string + boundaryText string + scriptKey string + }{ + {"kids_english", "少儿英语试听测评预约礼", "试听权益", "最终学费", "试听测评"}, + {"finance_advisor", "金融顾问合规初评预约", "咨询预约", "收益承诺", "持牌顾问"}, + {"home_decoration", "免费量房与设计咨询季", "量房预约", "最终报价", "预约设计师"}, + } { + t.Run(tc.code, func(t *testing.T) { + seeds, err := promotionTemplateSeeds(tc.code, now) + if err != nil { + t.Fatalf("promotionTemplateSeeds() error = %v", err) + } + if len(seeds) != 1 { + t.Fatalf("expected one promotion seed, got %d", len(seeds)) + } + if seeds[0].Name != tc.name || seeds[0].PromotionType != tc.typeName { + t.Fatalf("unexpected promotion seed: %#v", seeds[0]) + } + if !strings.Contains(seeds[0].DiscountRule, tc.boundaryText) { + t.Fatalf("promotion should include boundary %q: %s", tc.boundaryText, seeds[0].DiscountRule) + } + if !strings.Contains(seeds[0].ScriptSuggestion, tc.scriptKey) && !strings.Contains(seeds[0].AppointmentBenefit, tc.scriptKey) { + t.Fatalf("promotion should include script key %q: %#v", tc.scriptKey, seeds[0]) + } + }) + } +} diff --git a/internal/services/sales_lead_service.go b/internal/services/sales_lead_service.go new file mode 100644 index 00000000..cd3d72f6 --- /dev/null +++ b/internal/services/sales_lead_service.go @@ -0,0 +1,2066 @@ +package services + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strconv" + "strings" + "time" + + "agent-desk/internal/events" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/eventbus" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var SalesLeadService = newSalesLeadService() + +func newSalesLeadService() *salesLeadService { + return &salesLeadService{} +} + +type salesLeadService struct { +} + +type extractedLeadInfo struct { + CustomerName string + CustomerNameExplicit bool + Phone string + WeChat string + City string + AddressHint string + BudgetMin int64 + BudgetMax int64 + InterestedProducts string + DemandSummary string + IntentLevel enums.SalesLeadIntent + BuyingStage enums.SalesLeadStage + AppointmentAt *time.Time + AppointmentTimeText string + AppointmentStore string + AppointmentPeople int + AppointmentRemark string + HasSignal bool +} + +type salesLeadMatch struct { + Lead *models.SalesLead + MergeKey string + MergeReason string +} + +var ( + leadMobilePattern = regexp.MustCompile(`1[3-9]\d{9}`) + leadWeChatPattern = regexp.MustCompile(`(?:微信|微信号|wx|VX|v信|加我)[号::\s]*([a-zA-Z][-_a-zA-Z0-9]{5,19})`) + leadArabicWanPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*万`) + leadArabicYuanPattern = regexp.MustCompile(`(\d{4,7})\s*(?:元|块|左右|以内|上下)?`) + leadChineseWanPattern = regexp.MustCompile(`([一二两三四五六七八九十])万([一二两三四五六七八九十])?`) + leadSurnamePattern = regexp.MustCompile(`(?:我姓|姓)([\p{Han}])`) + leadNamePattern = regexp.MustCompile(`(?:我叫|我是)([\p{Han}]{2,4})(?:,|。|,|\.|\s|$)`) + leadHonorificPattern = regexp.MustCompile(`([\p{Han}]{1,3})(先生|女士|小姐|老师)`) + leadCityPattern = regexp.MustCompile(`(?:我在|人在|位于|城市是|城市:|城市:)([\p{Han}]{2,8})`) + leadAddressHintPattern = regexp.MustCompile(`(?:小区|地址|门店附近|附近|住在)[::\s]*([\p{Han}A-Za-z0-9\-—_·]{2,30})`) + leadDatePattern = regexp.MustCompile(`(?:(20\d{2})[年\-/.])?(\d{1,2})[月\-/.](\d{1,2})[日号]?`) + leadTimePattern = regexp.MustCompile(`(?:(上午|中午|下午|晚上|晚间|傍晚|早上|周末|本周末|这周末|明天|后天|今天|周一|周二|周三|周四|周五|周六|周日|星期一|星期二|星期三|星期四|星期五|星期六|星期日)[\p{Han}A-Za-z0-9::点半左右前后\-— ]{0,12})`) + leadPeoplePattern = regexp.MustCompile(`(\d+|[一二两三四五六七八九十])\s*(?:个)?(?:人|位|大人|成人)`) + leadStorePattern = regexp.MustCompile(`(?:到|去|在|约|预约)([\p{Han}A-Za-z·\-—]{2,20}(?:店|门店|旗舰店|体验店|商场|广场))`) +) + +func (s *salesLeadService) Get(id int64) *models.SalesLead { + return repositories.SalesLeadRepository.Get(sqls.DB(), id) +} + +func (s *salesLeadService) FindFollowUps(leadID int64) []models.LeadFollowUp { + if leadID <= 0 { + return nil + } + return repositories.LeadFollowUpRepository.Find(sqls.DB(), sqls.NewCnd().Where("lead_id = ?", leadID).Desc("id")) +} + +func (s *salesLeadService) List(req request.SalesLeadListRequest) (list []models.SalesLead, paging *sqls.Paging) { + tx := s.buildListQuery(req) + var total int64 + if err := tx.Count(&total).Error; err != nil { + slog.Error("sales lead list count failed", "error", err) + } + if err := tx.Order("id DESC").Offset(req.Offset()).Limit(req.GetLimit()).Find(&list).Error; err != nil { + slog.Error("sales lead list scan failed", "error", err) + } + return list, &sqls.Paging{Page: req.GetPage(), Limit: req.GetLimit(), Total: total} +} + +func (s *salesLeadService) Export(req request.SalesLeadListRequest) []models.SalesLead { + var list []models.SalesLead + if err := s.buildListQuery(req).Order("id DESC").Limit(5000).Find(&list).Error; err != nil { + slog.Error("sales lead export scan failed", "error", err) + } + return list +} + +func (s *salesLeadService) buildListQuery(req request.SalesLeadListRequest) *gorm.DB { + tx := sqls.DB().Model(&models.SalesLead{}).Where("status <> ?", enums.SalesLeadStatusClosed) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + pat := "%" + kw + "%" + tx = tx.Where(`customer_name LIKE ? OR phone LIKE ? OR we_chat LIKE ? OR city LIKE ? OR demand_summary LIKE ? OR interested_products LIKE ?`, pat, pat, pat, pat, pat, pat) + } + if status := strings.TrimSpace(req.Status); status != "" { + tx = tx.Where("status = ?", status) + } + if intent := strings.TrimSpace(req.Intent); intent != "" { + tx = tx.Where("intent_level = ?", intent) + } + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrowStart := todayStart.AddDate(0, 0, 1) + tx = applySalesLeadTaskView(tx, strings.TrimSpace(req.TaskView), todayStart, tomorrowStart) + switch strings.TrimSpace(req.FollowUpStatus) { + case "overdue": + tx = tx.Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", todayStart) + case "today": + tx = tx.Where("next_follow_up_at >= ? AND next_follow_up_at < ?", todayStart, tomorrowStart) + case "scheduled": + tx = tx.Where("next_follow_up_at >= ?", tomorrowStart) + case "none": + tx = tx.Where("next_follow_up_at IS NULL") + } + switch strings.TrimSpace(req.AppointmentStatus) { + case "overdue": + tx = tx.Where("appointment_at IS NOT NULL AND appointment_at < ?", todayStart) + case "today": + tx = tx.Where("appointment_at >= ? AND appointment_at < ?", todayStart, tomorrowStart) + case "upcoming": + tx = tx.Where("appointment_at >= ?", tomorrowStart) + case "unscheduled": + tx = tx.Where("appointment_at IS NULL"). + Where("(buying_stage = ? OR appointment_time_text <> '' OR appointment_store <> '')", enums.SalesLeadStageAppointment) + case "all": + tx = tx.Where("(buying_stage = ? OR appointment_at IS NOT NULL OR appointment_time_text <> '' OR appointment_store <> '')", enums.SalesLeadStageAppointment) + } + if req.OwnerUserID != nil { + if *req.OwnerUserID > 0 { + tx = tx.Where("owner_user_id = ?", *req.OwnerUserID) + } else if *req.OwnerUserID == -1 { + tx = tx.Where("owner_user_id = 0") + } + } + return tx +} + +func applySalesLeadTaskView(tx *gorm.DB, taskView string, todayStart, tomorrowStart time.Time) *gorm.DB { + activeLeadStatuses := []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} + switch taskView { + case "today": + return tx.Where("status IN ?", activeLeadStatuses). + Where("next_follow_up_at >= ? AND next_follow_up_at < ?", todayStart, tomorrowStart) + case "overdue": + return tx.Where("status IN ?", activeLeadStatuses). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", todayStart) + case "high_intent": + return tx.Where("status IN ?", activeLeadStatuses). + Where("intent_level = ?", enums.SalesLeadIntentHigh) + case "appointment": + return tx.Where("status IN ?", activeLeadStatuses). + Where("(buying_stage = ? OR appointment_at IS NOT NULL OR appointment_time_text <> '' OR appointment_store <> '')", enums.SalesLeadStageAppointment) + case "after_sales": + return tx.Where("status IN ?", activeLeadStatuses). + Where("buying_stage = ?", enums.SalesLeadStageAfterSales) + default: + return tx + } +} + +func (s *salesLeadService) Update(req request.UpdateSalesLeadRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := repositories.SalesLeadRepository.Get(sqls.DB(), req.ID) + if item == nil { + return errorsx.InvalidParam("sales lead not found") + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = string(item.Status) + } + if !enums.IsValidSalesLeadStatus(status) { + return errorsx.InvalidParam("invalid sales lead status") + } + updates := map[string]any{ + "customer_name": strings.TrimSpace(req.CustomerName), + "phone": normalizeLeadPhone(req.Phone), + "we_chat": strings.TrimSpace(req.WeChat), + "city": strings.TrimSpace(req.City), + "address_hint": strings.TrimSpace(req.AddressHint), + "budget_min": req.BudgetMin, + "budget_max": req.BudgetMax, + "interested_products": strings.TrimSpace(req.InterestedProducts), + "demand_summary": strings.TrimSpace(req.DemandSummary), + "intent_level": normalizeLeadIntent(req.IntentLevel, item.IntentLevel), + "buying_stage": normalizeLeadStage(req.BuyingStage, item.BuyingStage), + "appointment_at": parseLeadTimePtr(req.AppointmentAt), + "appointment_time_text": strings.TrimSpace(req.AppointmentTimeText), + "appointment_store": strings.TrimSpace(req.AppointmentStore), + "appointment_people": req.AppointmentPeople, + "appointment_remark": strings.TrimSpace(req.AppointmentRemark), + "owner_user_id": req.OwnerUserID, + "status": enums.SalesLeadStatus(status), + "remark": strings.TrimSpace(req.Remark), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + return repositories.SalesLeadRepository.Updates(sqls.DB(), item.ID, updates) +} + +func (s *salesLeadService) Assign(req request.AssignSalesLeadRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if repositories.SalesLeadRepository.Get(sqls.DB(), req.ID) == nil { + return errorsx.InvalidParam("sales lead not found") + } + return repositories.SalesLeadRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "owner_user_id": req.OwnerUserID, + "status": enums.SalesLeadStatusFollowing, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} + +func (s *salesLeadService) UpdateStatus(req request.UpdateSalesLeadStatusRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + item := repositories.SalesLeadRepository.Get(sqls.DB(), req.ID) + if item == nil { + return errorsx.InvalidParam("sales lead not found") + } + status := strings.TrimSpace(req.Status) + if !enums.IsValidSalesLeadStatus(status) { + return errorsx.InvalidParam("invalid sales lead status") + } + updates := map[string]any{ + "status": enums.SalesLeadStatus(status), + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + } + if remark := strings.TrimSpace(req.Remark); remark != "" { + if item.Remark == "" { + updates["remark"] = remark + } else if !strings.Contains(item.Remark, remark) { + updates["remark"] = limitText(item.Remark+"\n"+remark, 1000) + } + } + return repositories.SalesLeadRepository.Updates(sqls.DB(), item.ID, updates) +} + +func (s *salesLeadService) SyncToCRM(req request.SyncSalesLeadToCRMRequest, operator *dto.AuthPrincipal) (response.SalesLeadCRMSyncResponse, error) { + if operator == nil { + return response.SalesLeadCRMSyncResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + lead := repositories.SalesLeadRepository.Get(sqls.DB(), req.ID) + if lead == nil { + return response.SalesLeadCRMSyncResponse{}, errorsx.InvalidParam("sales lead not found") + } + title := fmt.Sprintf("销售线索 CRM 同步 #%d", lead.ID) + body := buildSalesLeadCRMWebhookText(lead, req.Remark) + ret := response.SalesLeadCRMSyncResponse{ + LeadID: lead.ID, + GeneratedAt: time.Now().Format(time.DateTime), + WebhookEnabled: WebhookNotifyService.Enabled(), + Title: title, + Message: "CRM 同步请求已生成。", + WebhookEventType: "sales_lead_crm_sync", + } + if !WebhookNotifyService.Enabled() { + ret.Message = "外部 Webhook 未启用,线索未同步到 CRM。" + return ret, nil + } + if err := WebhookNotifyService.SendText(ret.WebhookEventType, title, body, buildSalesLeadCRMWebhookMetadata(lead, operator, req.Remark)); err != nil { + ret.Message = "线索同步到 CRM 失败。" + return ret, err + } + ret.Sent = true + ret.Message = "线索已同步到 CRM Webhook。" + return ret, nil +} + +func (s *salesLeadService) ClaimUnassigned(req request.ClaimUnassignedSalesLeadsRequest, operator *dto.AuthPrincipal) (response.ClaimUnassignedSalesLeadsResponse, error) { + if operator == nil { + return response.ClaimUnassignedSalesLeadsResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + if operator.UserID <= 0 { + return response.ClaimUnassignedSalesLeadsResponse{}, errorsx.InvalidParam("invalid operator") + } + listReq := req.ToListRequest() + var leads []models.SalesLead + if err := s.buildListQuery(listReq). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Order("id DESC"). + Limit(req.GetLimit()). + Find(&leads).Error; err != nil { + return response.ClaimUnassignedSalesLeadsResponse{}, err + } + ids := make([]int64, 0, len(leads)) + for _, lead := range leads { + ids = append(ids, lead.ID) + } + if len(ids) == 0 { + return response.ClaimUnassignedSalesLeadsResponse{ + LeadIDs: []int64{}, + Message: "当前没有可领取的未分配线索", + }, nil + } + updateResult := sqls.DB().Model(&models.SalesLead{}). + Where("id IN ?", ids). + Where("owner_user_id = 0"). + Updates(map[string]any{ + "owner_user_id": operator.UserID, + "status": enums.SalesLeadStatusFollowing, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) + if err := updateResult.Error; err != nil { + return response.ClaimUnassignedSalesLeadsResponse{}, err + } + return response.ClaimUnassignedSalesLeadsResponse{ + ClaimedCount: updateResult.RowsAffected, + LeadIDs: ids, + Message: fmt.Sprintf("已领取 %d 条未分配线索", updateResult.RowsAffected), + }, nil +} + +func (s *salesLeadService) CreateFollowUp(req request.CreateLeadFollowUpRequest, operator *dto.AuthPrincipal) (*models.LeadFollowUp, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + lead := repositories.SalesLeadRepository.Get(sqls.DB(), req.LeadID) + if lead == nil { + return nil, errorsx.InvalidParam("sales lead not found") + } + content := strings.TrimSpace(req.Content) + if content == "" { + return nil, errorsx.InvalidParam("follow-up content is required") + } + var nextAt *time.Time + if raw := strings.TrimSpace(req.NextFollowUpAt); raw != "" { + parsed, err := time.ParseInLocation(time.DateTime, raw, time.Local) + if err != nil { + return nil, errorsx.InvalidParam("invalid next follow-up time") + } + nextAt = &parsed + } + item := &models.LeadFollowUp{ + LeadID: lead.ID, + OperatorID: operator.UserID, + OperatorName: operator.Username, + Content: content, + NextAction: strings.TrimSpace(req.NextAction), + NextFollowUpAt: nextAt, + CreatedAt: time.Now(), + } + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.LeadFollowUpRepository.Create(ctx.Tx, item); err != nil { + return err + } + return repositories.SalesLeadRepository.Updates(ctx.Tx, lead.ID, map[string]any{ + "status": enums.SalesLeadStatusFollowing, + "next_follow_up_at": nextAt, + "updated_at": time.Now(), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) + }); err != nil { + return nil, err + } + return item, nil +} + +func (s *salesLeadService) BuildFollowUpAdvice(lead *models.SalesLead, followUps []models.LeadFollowUp) response.SalesLeadFollowUpAdviceResult { + if lead == nil { + return response.SalesLeadFollowUpAdviceResult{} + } + customerName := strings.TrimSpace(lead.CustomerName) + if customerName == "" { + customerName = "客户" + } + contact := lead.Phone + if contact == "" { + contact = lead.WeChat + } + if contact == "" { + contact = "暂无联系方式" + } + + summaryParts := []string{customerName, contact} + if lead.City != "" { + summaryParts = append(summaryParts, lead.City) + } + if budget := formatSalesLeadAdviceBudget(lead.BudgetMin, lead.BudgetMax); budget != "" { + summaryParts = append(summaryParts, "预算"+budget) + } + if lead.InterestedProducts != "" { + summaryParts = append(summaryParts, "关注"+lead.InterestedProducts) + } + if lead.DemandSummary != "" { + summaryParts = append(summaryParts, limitText(lead.DemandSummary, 120)) + } + + nextAction := buildLeadNextAction(lead) + script := buildLeadFollowUpScript(customerName, lead, nextAction) + riskHints := buildLeadRiskHints(lead, followUps) + copyLines := []string{ + "【客户跟进摘要】", + "客户:" + customerName, + "联系方式:" + contact, + "阶段:" + string(lead.BuyingStage) + " / 意向:" + string(lead.IntentLevel), + } + if budget := formatSalesLeadAdviceBudget(lead.BudgetMin, lead.BudgetMax); budget != "" { + copyLines = append(copyLines, "预算:"+budget) + } + if lead.InterestedProducts != "" { + copyLines = append(copyLines, "意向产品:"+lead.InterestedProducts) + } + if lead.DemandSummary != "" { + copyLines = append(copyLines, "需求:"+limitText(lead.DemandSummary, 240)) + } + if appointment := buildLeadAppointmentText(lead); appointment != "" { + copyLines = append(copyLines, "预约:"+appointment) + } + if len(followUps) > 0 { + latest := followUps[0] + copyLines = append(copyLines, "最近跟进:"+limitText(latest.Content, 180)) + if latest.NextAction != "" { + copyLines = append(copyLines, "上次下一步:"+latest.NextAction) + } + } + copyLines = append(copyLines, "建议下一步:"+nextAction, "建议话术:"+script) + if len(riskHints) > 0 { + copyLines = append(copyLines, "注意事项:"+strings.Join(riskHints, ";")) + } + + return response.SalesLeadFollowUpAdviceResult{ + CustomerSummary: strings.Join(summaryParts, "|"), + NextAction: nextAction, + Script: script, + CopyText: strings.Join(copyLines, "\n"), + RiskHints: riskHints, + } +} + +func formatSalesLeadAdviceBudget(minValue, maxValue int64) string { + if minValue > 0 && maxValue > 0 { + if minValue == maxValue { + return fmt.Sprintf("%d 元左右", minValue) + } + return fmt.Sprintf("%d-%d 元", minValue, maxValue) + } + if minValue > 0 { + return fmt.Sprintf("%d 元以上", minValue) + } + if maxValue > 0 { + return fmt.Sprintf("%d 元以内", maxValue) + } + return "" +} + +func buildLeadAppointmentText(lead *models.SalesLead) string { + if lead == nil { + return "" + } + parts := []string{} + if lead.AppointmentAt != nil { + parts = append(parts, lead.AppointmentAt.Format(time.DateTime)) + } + if lead.AppointmentTimeText != "" { + parts = append(parts, lead.AppointmentTimeText) + } + if lead.AppointmentStore != "" { + parts = append(parts, lead.AppointmentStore) + } + if lead.AppointmentPeople > 0 { + parts = append(parts, fmt.Sprintf("%d人", lead.AppointmentPeople)) + } + if lead.AppointmentRemark != "" { + parts = append(parts, lead.AppointmentRemark) + } + return strings.Join(parts, " / ") +} + +func buildSalesLeadCRMWebhookText(lead *models.SalesLead, remark string) string { + if lead == nil { + return "" + } + lines := []string{ + fmt.Sprintf("线索ID: %d", lead.ID), + fmt.Sprintf("客户: %s", strs.DefaultIfBlank(strings.TrimSpace(lead.CustomerName), "未命名客户")), + fmt.Sprintf("联系方式: %s", salesLeadContactTextForService(lead)), + fmt.Sprintf("城市: %s", strs.DefaultIfBlank(strings.TrimSpace(lead.City), "-")), + fmt.Sprintf("意向: %s", string(lead.IntentLevel)), + fmt.Sprintf("阶段: %s", string(lead.BuyingStage)), + fmt.Sprintf("状态: %s", string(lead.Status)), + } + if lead.InterestedProducts != "" { + lines = append(lines, fmt.Sprintf("意向产品: %s", strings.TrimSpace(lead.InterestedProducts))) + } + if lead.BudgetMin > 0 || lead.BudgetMax > 0 { + lines = append(lines, fmt.Sprintf("预算: %s", buildLeadBudgetTextForCRM(lead))) + } + if appointment := buildLeadAppointmentText(lead); appointment != "" { + lines = append(lines, fmt.Sprintf("预约: %s", appointment)) + } + if lead.DemandSummary != "" { + lines = append(lines, fmt.Sprintf("需求: %s", strings.TrimSpace(lead.DemandSummary))) + } + if lead.SourceChannel != "" { + lines = append(lines, fmt.Sprintf("来源渠道: %s", strings.TrimSpace(lead.SourceChannel))) + } + if remark = strings.TrimSpace(remark); remark != "" { + lines = append(lines, fmt.Sprintf("同步备注: %s", remark)) + } + lines = append(lines, fmt.Sprintf("后台链接: /dashboard/sales-leads?leadId=%d", lead.ID)) + return strings.Join(lines, "\n") +} + +func buildSalesLeadCRMWebhookMetadata(lead *models.SalesLead, operator *dto.AuthPrincipal, remark string) map[string]any { + metadata := map[string]any{ + "leadId": lead.ID, + "customerId": lead.CustomerID, + "conversationId": lead.ConversationID, + "customerName": strings.TrimSpace(lead.CustomerName), + "phone": strings.TrimSpace(lead.Phone), + "wechat": strings.TrimSpace(lead.WeChat), + "city": strings.TrimSpace(lead.City), + "addressHint": strings.TrimSpace(lead.AddressHint), + "budgetMin": lead.BudgetMin, + "budgetMax": lead.BudgetMax, + "interestedProducts": strings.TrimSpace(lead.InterestedProducts), + "demandSummary": strings.TrimSpace(lead.DemandSummary), + "intentLevel": string(lead.IntentLevel), + "buyingStage": string(lead.BuyingStage), + "appointmentAt": utils.FormatTimePtr(lead.AppointmentAt), + "appointmentTimeText": strings.TrimSpace(lead.AppointmentTimeText), + "appointmentStore": strings.TrimSpace(lead.AppointmentStore), + "appointmentPeople": lead.AppointmentPeople, + "appointmentRemark": strings.TrimSpace(lead.AppointmentRemark), + "sourceChannel": strings.TrimSpace(lead.SourceChannel), + "ownerUserId": lead.OwnerUserID, + "status": string(lead.Status), + "nextFollowUpAt": utils.FormatTimePtr(lead.NextFollowUpAt), + "autoTags": buildSalesLeadCRMAutoTags(lead), + "autoTagDetails": buildSalesLeadCRMAutoTagDetails(lead), + "actionUrl": fmt.Sprintf("/dashboard/sales-leads?leadId=%d", lead.ID), + "remark": strings.TrimSpace(remark), + "operatorId": operator.UserID, + "operatorName": operator.Username, + } + return metadata +} + +func buildSalesLeadCRMAutoTags(lead *models.SalesLead) []string { + tags := make([]string, 0, 8) + add := func(label string) { + if strings.TrimSpace(label) == "" { + return + } + for _, existing := range tags { + if existing == label { + return + } + } + tags = append(tags, label) + } + if lead.Status == enums.SalesLeadStatusConverted { + add("已成交") + } + if lead.Status == enums.SalesLeadStatusVisited { + add("已到店") + } + if lead.IntentLevel == enums.SalesLeadIntentHigh { + add("高意向") + } + if lead.BuyingStage == enums.SalesLeadStageReadyToBuy { + add("准成交") + } + if lead.BuyingStage == enums.SalesLeadStageAppointment || lead.AppointmentAt != nil || lead.AppointmentTimeText != "" || lead.AppointmentStore != "" { + add("已预约") + } + if lead.BuyingStage == enums.SalesLeadStageAfterSales { + add("售后风险") + } + if lead.Phone == "" && lead.WeChat == "" { + add("待补联系方式") + } else { + add("已留联系方式") + } + if lead.BudgetMin > 0 || lead.BudgetMax > 0 { + add("有预算") + } + if lead.BudgetMax >= 20000 || lead.BudgetMin >= 20000 { + add("高预算") + } + if lead.SourceChannel != "" { + add("渠道:" + strings.TrimSpace(lead.SourceChannel)) + } + return tags +} + +func buildSalesLeadCRMAutoTagDetails(lead *models.SalesLead) []map[string]string { + labels := buildSalesLeadCRMAutoTags(lead) + ret := make([]map[string]string, 0, len(labels)) + for _, label := range labels { + ret = append(ret, map[string]string{ + "label": label, + "reason": salesLeadCRMAutoTagReason(label), + "actionLabel": salesLeadCRMAutoTagAction(label), + }) + } + return ret +} + +func salesLeadCRMAutoTagReason(label string) string { + switch { + case label == "已成交": + return "线索状态已成交。" + case label == "已到店": + return "客户已完成到店标记。" + case label == "高意向": + return "客户购买意向较强。" + case label == "准成交": + return "客户进入准成交阶段。" + case label == "已预约": + return "客户已有预约信息。" + case label == "售后风险": + return "客户诉求涉及售后或投诉。" + case label == "待补联系方式": + return "手机号和微信都为空。" + case label == "已留联系方式": + return "客户已留下手机号或微信。" + case label == "有预算": + return "线索已抽取到预算信息。" + case label == "高预算": + return "预算达到高客单阈值。" + case strings.HasPrefix(label, "渠道:"): + return "线索带有来源渠道标识。" + default: + return "系统根据线索状态自动生成。" + } +} + +func salesLeadCRMAutoTagAction(label string) string { + switch { + case label == "高意向": + return "优先跟进" + case label == "准成交": + return "确认报价与下单障碍" + case label == "已预约": + return "发送到店提醒" + case label == "售后风险": + return "转售后处理" + case label == "待补联系方式": + return "补齐联系方式" + case label == "高预算": + return "推荐高客单方案" + case strings.HasPrefix(label, "渠道:"): + return "复盘渠道效果" + default: + return "查看线索详情" + } +} + +func buildLeadBudgetTextForCRM(lead *models.SalesLead) string { + if lead.BudgetMin > 0 && lead.BudgetMax > 0 { + return fmt.Sprintf("%d-%d 元", lead.BudgetMin, lead.BudgetMax) + } + if lead.BudgetMin > 0 { + return fmt.Sprintf("%d 元以上", lead.BudgetMin) + } + if lead.BudgetMax > 0 { + return fmt.Sprintf("%d 元左右", lead.BudgetMax) + } + return "-" +} + +func salesLeadContactTextForService(lead *models.SalesLead) string { + parts := make([]string, 0, 2) + if phone := strings.TrimSpace(lead.Phone); phone != "" { + parts = append(parts, phone) + } + if wechat := strings.TrimSpace(lead.WeChat); wechat != "" { + parts = append(parts, "微信 "+wechat) + } + if len(parts) == 0 { + return "暂无" + } + return strings.Join(parts, " / ") +} + +func buildLeadNextAction(lead *models.SalesLead) string { + if lead == nil { + return "补充客户需求并确认下一步。" + } + if lead.BuyingStage == enums.SalesLeadStageAfterSales { + return "先安抚客户并确认售后问题、订单信息、购买门店和可联系时间,必要时同步售后负责人。" + } + if lead.AppointmentAt != nil { + return "确认预约时间、到店门店、同行人数和重点体验产品,并提醒顾问提前准备接待。" + } + if lead.BuyingStage == enums.SalesLeadStageAppointment || lead.AppointmentTimeText != "" || lead.AppointmentStore != "" { + return "把预约意向补成明确到店时间、门店和人数,并发送到店提醒。" + } + if lead.IntentLevel == enums.SalesLeadIntentHigh || lead.BuyingStage == enums.SalesLeadStageReadyToBuy { + return "尽快电话或微信联系,确认预算、尺寸、使用人和到店时间,推动预约或人工报价。" + } + if lead.Phone == "" && lead.WeChat == "" { + return "先补齐手机号或微信,再确认预算、使用场景和是否方便到店体验。" + } + if lead.InterestedProducts == "" { + return "追问使用人、软硬偏好、尺寸和预算,再匹配 1-2 个主推产品。" + } + return "围绕客户关注产品确认使用场景、预算和试躺时间,记录下次跟进计划。" +} + +func buildLeadFollowUpScript(customerName string, lead *models.SalesLead, nextAction string) string { + name := strings.TrimSpace(customerName) + if name == "" || name == "客户" { + name = "您好" + } else { + name = name + "您好" + } + product := strings.TrimSpace(lead.InterestedProducts) + if product == "" { + product = "适合您的产品" + } + need := strings.TrimSpace(lead.DemandSummary) + if need == "" { + need = "您前面咨询的需求" + } + if lead.BuyingStage == enums.SalesLeadStageAfterSales { + return fmt.Sprintf("%s,我看到您反馈了售后问题。为了尽快帮您处理,我先和您确认一下购买门店、订单信息、具体问题表现和方便联系的时间。", name) + } + if lead.AppointmentAt != nil || lead.BuyingStage == enums.SalesLeadStageAppointment { + appointment := buildLeadAppointmentText(lead) + if appointment == "" { + appointment = "您方便的到店时间" + } + return fmt.Sprintf("%s,我这边看到您想了解%s,也提到%s。我们先帮您预留%s的体验安排,到店重点试%s,您看这个时间是否方便?", name, product, need, appointment, product) + } + if lead.IntentLevel == enums.SalesLeadIntentHigh || lead.BuyingStage == enums.SalesLeadStageReadyToBuy { + return fmt.Sprintf("%s,我看您对%s比较感兴趣,也提到%s。我先帮您按预算和使用场景筛一下合适方案,再确认是否需要预约到店试躺或让门店顾问给您报价。", name, product, need) + } + return fmt.Sprintf("%s,我看到您前面咨询了%s。为了推荐更准,我想再确认一下使用人、尺寸、预算和软硬偏好,然后给您整理两套更适合的方案。", name, need) +} + +func buildLeadRiskHints(lead *models.SalesLead, followUps []models.LeadFollowUp) []string { + hints := []string{} + if lead == nil { + return hints + } + if lead.Phone == "" && lead.WeChat == "" { + hints = append(hints, "缺少联系方式") + } + if lead.OwnerUserID == 0 { + hints = append(hints, "未分配负责人") + } + if lead.IntentLevel == enums.SalesLeadIntentHigh && lead.NextFollowUpAt == nil { + hints = append(hints, "高意向但未设置下次跟进") + } + if lead.BuyingStage == enums.SalesLeadStageAppointment && lead.AppointmentAt == nil && lead.AppointmentTimeText == "" { + hints = append(hints, "预约意向未确认具体时间") + } + if lead.BuyingStage == enums.SalesLeadStageAfterSales { + hints = append(hints, "售后/投诉场景需优先处理") + } + if len(followUps) == 0 && lead.Status == enums.SalesLeadStatusFollowing { + hints = append(hints, "跟进中但暂无跟进记录") + } + return hints +} + +func (s *salesLeadService) GetFollowUpReminderSummary(req request.SalesLeadFollowUpReminderRequest) response.SalesLeadFollowUpReminderSummaryResponse { + now := time.Now() + todayStart, tomorrowStart := leadDayBounds(now) + ret := response.SalesLeadFollowUpReminderSummaryResponse{ + GeneratedAt: utils.FormatTime(now), + } + _ = s.buildFollowUpReminderQuery(req). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", todayStart). + Count(&ret.OverdueCount).Error + _ = s.buildFollowUpReminderQuery(req). + Where("next_follow_up_at >= ? AND next_follow_up_at < ?", todayStart, tomorrowStart). + Count(&ret.TodayCount).Error + ret.DueCount = ret.OverdueCount + ret.TodayCount + _ = s.buildFollowUpReminderQuery(req). + Where("owner_user_id = 0"). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", tomorrowStart). + Count(&ret.UnassignedDueCount).Error + _ = s.buildFollowUpReminderQuery(req). + Where("next_follow_up_at IS NULL"). + Count(&ret.MissingScheduleCount).Error + + var preview []models.SalesLead + if err := s.buildFollowUpReminderQuery(req). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", tomorrowStart). + Order("next_follow_up_at ASC, id ASC"). + Limit(req.GetLimit()). + Find(&preview).Error; err != nil { + slog.Error("load sales lead follow-up reminder preview failed", "error", err) + } + ret.PreviewLeads = s.buildFollowUpReminderPreview(preview, todayStart, tomorrowStart) + ret.Message = buildSalesLeadFollowUpReminderBody(ret) + return ret +} + +func (s *salesLeadService) SendFollowUpReminder(req request.SalesLeadFollowUpReminderRequest, operator *dto.AuthPrincipal) (response.SalesLeadFollowUpReminderSummaryResponse, error) { + if operator == nil { + return response.SalesLeadFollowUpReminderSummaryResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + summary := s.GetFollowUpReminderSummary(req) + if summary.DueCount == 0 && summary.MissingScheduleCount == 0 { + return summary, nil + } + recipients := s.resolveFollowUpReminderRecipients(req, operator.UserID) + title := "销售线索跟进提醒" + for _, recipientID := range recipients { + if _, err := NotificationService.CreateAndPush(request.CreateNotificationRequest{ + RecipientUserID: recipientID, + Title: title, + Content: summary.Message, + NotificationType: "sales_lead_follow_up_reminder", + BizType: "sales_lead", + BizID: 0, + ActionURL: "/dashboard/sales-leads?followUpStatus=today", + }); err != nil { + slog.Error("create sales lead follow-up reminder notification failed", "error", err, "recipientUserId", recipientID) + } else { + summary.NotificationSent = true + } + } + if err := WebhookNotifyService.SendText("sales_lead_follow_up_reminder", title, summary.Message, map[string]any{ + "overdueCount": summary.OverdueCount, + "todayCount": summary.TodayCount, + "dueCount": summary.DueCount, + "unassignedDueCount": summary.UnassignedDueCount, + "missingScheduleCount": summary.MissingScheduleCount, + "operatorId": operator.UserID, + }); err != nil { + slog.Error("send sales lead follow-up reminder webhook failed", "error", err) + } else if WebhookNotifyService.Enabled() { + summary.NotificationSent = true + } + return summary, nil +} + +func (s *salesLeadService) GetAppointmentSummary(req request.SalesLeadAppointmentSummaryRequest) response.SalesLeadAppointmentSummaryResponse { + now := time.Now() + todayStart, tomorrowStart := leadDayBounds(now) + windowEnd := todayStart.AddDate(0, 0, req.GetDays()+1) + ret := response.SalesLeadAppointmentSummaryResponse{ + GeneratedAt: utils.FormatTime(now), + Days: req.GetDays(), + } + _ = s.buildAppointmentQuery(req). + Where("appointment_at IS NOT NULL AND appointment_at < ?", todayStart). + Count(&ret.OverdueCount).Error + _ = s.buildAppointmentQuery(req). + Where("appointment_at >= ? AND appointment_at < ?", todayStart, tomorrowStart). + Count(&ret.TodayCount).Error + _ = s.buildAppointmentQuery(req). + Where("appointment_at >= ? AND appointment_at < ?", tomorrowStart, windowEnd). + Count(&ret.UpcomingCount).Error + _ = s.buildAppointmentQuery(req). + Where("appointment_at IS NULL"). + Count(&ret.UnscheduledCount).Error + _ = s.buildAppointmentQuery(req). + Where("owner_user_id = 0"). + Count(&ret.UnassignedCount).Error + + var preview []models.SalesLead + if err := s.buildAppointmentQuery(req). + Order("CASE WHEN appointment_at IS NULL THEN 1 ELSE 0 END ASC"). + Order("appointment_at ASC"). + Order("id DESC"). + Limit(req.GetLimit()). + Find(&preview).Error; err != nil { + slog.Error("load sales lead appointment preview failed", "error", err) + } + ret.PreviewAppointments = s.buildAppointmentPreview(preview, todayStart, tomorrowStart) + ret.Message = buildSalesLeadAppointmentSummaryMessage(ret) + return ret +} + +func (s *salesLeadService) SendAppointmentReminder(req request.SalesLeadAppointmentSummaryRequest, operator *dto.AuthPrincipal) (response.SalesLeadAppointmentSummaryResponse, error) { + if operator == nil { + return response.SalesLeadAppointmentSummaryResponse{}, errorsx.UnauthorizedI18n("error.auth.expired") + } + summary := s.GetAppointmentSummary(req) + if summary.OverdueCount == 0 && summary.TodayCount == 0 && summary.UnscheduledCount == 0 { + return summary, nil + } + recipients := s.resolveAppointmentReminderRecipients(req, operator.UserID) + title := "销售线索预约提醒" + for _, recipientID := range recipients { + if _, err := NotificationService.CreateAndPush(request.CreateNotificationRequest{ + RecipientUserID: recipientID, + Title: title, + Content: summary.Message, + NotificationType: "sales_lead_appointment_reminder", + BizType: "sales_lead", + BizID: 0, + ActionURL: "/dashboard/sales-leads", + }); err != nil { + slog.Error("create sales lead appointment reminder notification failed", "error", err, "recipientUserId", recipientID) + } else { + summary.NotificationSent = true + } + } + if err := WebhookNotifyService.SendText("sales_lead_appointment_reminder", title, summary.Message, map[string]any{ + "overdueCount": summary.OverdueCount, + "todayCount": summary.TodayCount, + "upcomingCount": summary.UpcomingCount, + "unscheduledCount": summary.UnscheduledCount, + "unassignedCount": summary.UnassignedCount, + "operatorId": operator.UserID, + }); err != nil { + slog.Error("send sales lead appointment reminder webhook failed", "error", err) + } else if WebhookNotifyService.Enabled() { + summary.NotificationSent = true + } + return summary, nil +} + +func (s *salesLeadService) buildFollowUpReminderQuery(req request.SalesLeadFollowUpReminderRequest) *gorm.DB { + tx := sqls.DB().Model(&models.SalesLead{}). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Where("status <> ?", enums.SalesLeadStatusClosed) + if req.OwnerUserID != nil && *req.OwnerUserID > 0 { + tx = tx.Where("owner_user_id = ?", *req.OwnerUserID) + } + return tx +} + +func (s *salesLeadService) buildAppointmentQuery(req request.SalesLeadAppointmentSummaryRequest) *gorm.DB { + tx := sqls.DB().Model(&models.SalesLead{}). + Where("status IN ?", []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing}). + Where("(buying_stage = ? OR appointment_at IS NOT NULL OR appointment_time_text <> '' OR appointment_store <> '')", enums.SalesLeadStageAppointment) + if req.OwnerUserID != nil && *req.OwnerUserID > 0 { + tx = tx.Where("owner_user_id = ?", *req.OwnerUserID) + } + return tx +} + +func (s *salesLeadService) buildFollowUpReminderPreview(list []models.SalesLead, todayStart, tomorrowStart time.Time) []response.SalesLeadFollowUpReminderLeadResponse { + ret := make([]response.SalesLeadFollowUpReminderLeadResponse, 0, len(list)) + for i := range list { + item := list[i] + ownerName := "" + if owner := UserService.Get(item.OwnerUserID); owner != nil { + ownerName = owner.Username + } + ret = append(ret, response.SalesLeadFollowUpReminderLeadResponse{ + ID: item.ID, + CustomerName: item.CustomerName, + Phone: item.Phone, + WeChat: item.WeChat, + IntentLevel: item.IntentLevel, + Status: item.Status, + OwnerUserID: item.OwnerUserID, + OwnerUserName: ownerName, + NextFollowUpAt: utils.FormatTimePtr(item.NextFollowUpAt), + FollowUpState: leadFollowUpState(item.NextFollowUpAt, todayStart, tomorrowStart), + DemandSummary: limitText(item.DemandSummary, 120), + ActionURL: fmt.Sprintf("/dashboard/sales-leads?leadId=%d", item.ID), + }) + } + return ret +} + +func (s *salesLeadService) buildAppointmentPreview(list []models.SalesLead, todayStart, tomorrowStart time.Time) []response.SalesLeadAppointmentItemResponse { + ret := make([]response.SalesLeadAppointmentItemResponse, 0, len(list)) + for i := range list { + item := list[i] + ownerName := "" + if owner := UserService.Get(item.OwnerUserID); owner != nil { + ownerName = owner.Username + } + ret = append(ret, response.SalesLeadAppointmentItemResponse{ + ID: item.ID, + CustomerName: item.CustomerName, + Phone: item.Phone, + WeChat: item.WeChat, + IntentLevel: item.IntentLevel, + Status: item.Status, + OwnerUserID: item.OwnerUserID, + OwnerUserName: ownerName, + AppointmentAt: utils.FormatTimePtr(item.AppointmentAt), + AppointmentTimeText: item.AppointmentTimeText, + AppointmentStore: item.AppointmentStore, + AppointmentPeople: item.AppointmentPeople, + DemandSummary: limitText(item.DemandSummary, 120), + AppointmentState: leadAppointmentState(item.AppointmentAt, todayStart, tomorrowStart), + ActionURL: fmt.Sprintf("/dashboard/sales-leads?leadId=%d", item.ID), + }) + } + return ret +} + +func (s *salesLeadService) resolveAppointmentReminderRecipients(req request.SalesLeadAppointmentSummaryRequest, operatorID int64) []int64 { + recipients := map[int64]struct{}{} + if operatorID > 0 { + recipients[operatorID] = struct{}{} + } + _, tomorrowStart := leadDayBounds(time.Now()) + var ownerIDs []int64 + if err := s.buildAppointmentQuery(req). + Where("owner_user_id > 0"). + Where("(appointment_at IS NULL OR appointment_at < ?)", tomorrowStart). + Distinct("owner_user_id"). + Pluck("owner_user_id", &ownerIDs).Error; err != nil { + slog.Error("load appointment reminder recipients failed", "error", err) + } + for _, ownerID := range ownerIDs { + if ownerID > 0 { + recipients[ownerID] = struct{}{} + } + } + ret := make([]int64, 0, len(recipients)) + for id := range recipients { + ret = append(ret, id) + } + return ret +} + +func (s *salesLeadService) resolveFollowUpReminderRecipients(req request.SalesLeadFollowUpReminderRequest, operatorID int64) []int64 { + recipients := map[int64]struct{}{} + if operatorID > 0 { + recipients[operatorID] = struct{}{} + } + var ownerIDs []int64 + now := time.Now() + _, tomorrowStart := leadDayBounds(now) + if err := s.buildFollowUpReminderQuery(req). + Where("owner_user_id > 0"). + Where("next_follow_up_at IS NOT NULL AND next_follow_up_at < ?", tomorrowStart). + Distinct("owner_user_id"). + Pluck("owner_user_id", &ownerIDs).Error; err != nil { + slog.Error("load sales lead follow-up reminder owners failed", "error", err) + } + for _, ownerID := range ownerIDs { + if ownerID > 0 { + recipients[ownerID] = struct{}{} + } + } + ret := make([]int64, 0, len(recipients)) + for id := range recipients { + ret = append(ret, id) + } + return ret +} + +func (s *salesLeadService) ExtractFromCustomerMessageAsync(conversation models.Conversation, message models.Message) { + go func() { + if err := s.ExtractFromCustomerMessage(conversation, message); err != nil { + slog.Error("extract sales lead from customer message failed", + "conversationId", conversation.ID, + "messageId", message.ID, + "error", err, + ) + } + }() +} + +func (s *salesLeadService) ExtractFromCustomerMessage(conversation models.Conversation, message models.Message) error { + if conversation.ID <= 0 || message.ID <= 0 || message.SenderType != enums.IMSenderTypeCustomer || message.MessageType != enums.IMMessageTypeText { + return nil + } + info := extractLeadInfo(message.Content) + if !info.HasSignal { + return nil + } + if info.CustomerName == "" { + info.CustomerName = strings.TrimSpace(conversation.CustomerName) + } + if info.DemandSummary == "" { + info.DemandSummary = limitText(message.Content, 500) + } + channelType := "" + if channel := ChannelService.Get(conversation.ChannelID); channel != nil { + channelType = channel.ChannelType + } + var notifyEvent *events.SalesLeadCreatedEvent + var shouldEnsureAfterSalesTicket bool + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + customerID, err := s.ensureLeadCustomerProfile(ctx.Tx, &conversation, info) + if err != nil { + return err + } + if customerID > 0 { + conversation.CustomerID = customerID + } + match := s.findExistingLead(ctx.Tx, conversation.ID, conversation.CustomerID, info.Phone, info.WeChat) + if match.Lead == nil { + now := time.Now() + lead := &models.SalesLead{ + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + CustomerName: info.CustomerName, + Phone: info.Phone, + WeChat: info.WeChat, + City: info.City, + AddressHint: info.AddressHint, + BudgetMin: info.BudgetMin, + BudgetMax: info.BudgetMax, + InterestedProducts: info.InterestedProducts, + DemandSummary: info.DemandSummary, + IntentLevel: info.IntentLevel, + BuyingStage: info.BuyingStage, + AppointmentAt: info.AppointmentAt, + AppointmentTimeText: info.AppointmentTimeText, + AppointmentStore: info.AppointmentStore, + AppointmentPeople: info.AppointmentPeople, + AppointmentRemark: info.AppointmentRemark, + SourceChannel: channelType, + Status: enums.SalesLeadStatusNew, + LastMessageID: message.ID, + MergeKey: "new", + MergeReason: buildSalesLeadMergeReason("new", 0, conversation.ID, conversation.CustomerID, info.Phone, info.WeChat), + MergedAt: &now, + AuditFields: utils.BuildAuditFields(nil), + } + lead.CreateUserName = "system" + lead.UpdateUserName = "system" + if err := repositories.SalesLeadRepository.Create(ctx.Tx, lead); err != nil { + return err + } + if isActionableLeadInfo(info) { + notifyEvent = &events.SalesLeadCreatedEvent{ + LeadID: lead.ID, + ConversationID: conversation.ID, + Reason: leadNotifyReasonFromInfo(info), + } + } + shouldEnsureAfterSalesTicket = info.BuyingStage == enums.SalesLeadStageAfterSales + return nil + } + lead := match.Lead + if shouldNotifySalesLeadUpdate(lead, info) { + notifyEvent = &events.SalesLeadCreatedEvent{ + LeadID: lead.ID, + ConversationID: conversation.ID, + Reason: leadNotifyReasonFromInfo(info), + } + } + shouldEnsureAfterSalesTicket = info.BuyingStage == enums.SalesLeadStageAfterSales || lead.BuyingStage == enums.SalesLeadStageAfterSales + return repositories.SalesLeadRepository.Updates(ctx.Tx, lead.ID, mergeLeadUpdates(lead, info, conversation, message.ID, channelType, match)) + }); err != nil { + return err + } + if shouldEnsureAfterSalesTicket { + if _, err := TicketService.EnsureAfterSalesTicketFromConversation(conversation, "售后/投诉风险待处理", info.DemandSummary); err != nil { + slog.Error("ensure after-sales ticket from sales lead failed", "conversationId", conversation.ID, "messageId", message.ID, "error", err) + } + } + if notifyEvent != nil && notifyEvent.LeadID > 0 { + eventbus.PublishAsync(context.Background(), *notifyEvent) + } + return nil +} + +func (s *salesLeadService) findExistingLead(db *gorm.DB, conversationID, customerID int64, phone, wechat string) salesLeadMatch { + if conversationID > 0 { + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("conversation_id = ?", conversationID). + Where("status <> ?", enums.SalesLeadStatusClosed)); lead != nil { + return salesLeadMatch{ + Lead: lead, + MergeKey: "conversation", + MergeReason: buildSalesLeadMergeReason("conversation", lead.ID, conversationID, customerID, phone, wechat), + } + } + } + phone = normalizeLeadPhone(phone) + if phone != "" { + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("phone = ?", phone). + Where("status IN ?", activeSalesLeadStatuses()). + Desc("id")); lead != nil { + return salesLeadMatch{ + Lead: lead, + MergeKey: "phone", + MergeReason: buildSalesLeadMergeReason("phone", lead.ID, conversationID, customerID, phone, wechat), + } + } + } + wechat = strings.TrimSpace(wechat) + if wechat != "" { + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("we_chat = ?", wechat). + Where("status IN ?", activeSalesLeadStatuses()). + Desc("id")); lead != nil { + return salesLeadMatch{ + Lead: lead, + MergeKey: "wechat", + MergeReason: buildSalesLeadMergeReason("wechat", lead.ID, conversationID, customerID, phone, wechat), + } + } + } + if customerID > 0 { + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("status IN ?", activeSalesLeadStatuses()).Desc("id")); lead != nil { + return salesLeadMatch{ + Lead: lead, + MergeKey: "customer", + MergeReason: buildSalesLeadMergeReason("customer", lead.ID, conversationID, customerID, phone, wechat), + } + } + } + return salesLeadMatch{} +} + +func activeSalesLeadStatuses() []enums.SalesLeadStatus { + return []enums.SalesLeadStatus{enums.SalesLeadStatusNew, enums.SalesLeadStatusFollowing} +} + +func buildSalesLeadMergeReason(key string, leadID int64, conversationID int64, customerID int64, phone string, wechat string) string { + switch key { + case "conversation": + return fmt.Sprintf("同一会话 #%d 已有未关闭线索,继续补充线索 #%d。", conversationID, leadID) + case "phone": + return fmt.Sprintf("手机号 %s 命中活跃线索 #%d,跨会话合并到该线索。", normalizeLeadPhone(phone), leadID) + case "wechat": + return fmt.Sprintf("微信 %s 命中活跃线索 #%d,跨会话合并到该线索。", strings.TrimSpace(wechat), leadID) + case "customer": + return fmt.Sprintf("客户档案 #%d 已有活跃线索 #%d,复用客户线索继续跟进。", customerID, leadID) + default: + parts := []string{"未匹配到同会话、手机号、微信或客户档案的活跃线索,已创建新线索。"} + if conversationID > 0 { + parts = append(parts, fmt.Sprintf("来源会话 #%d。", conversationID)) + } + return strings.Join(parts, "") + } +} + +func (s *salesLeadService) ensureLeadCustomerProfile(db *gorm.DB, conversation *models.Conversation, info extractedLeadInfo) (int64, error) { + if conversation == nil { + return 0, nil + } + customerID := conversation.CustomerID + if customerID <= 0 && hasLeadCustomerProfileSignal(info) { + customerID = s.findCustomerIDByLeadProfile(db, info) + if customerID <= 0 { + createdID, err := s.createLeadCustomerProfile(db, conversation, info) + if err != nil { + return 0, err + } + customerID = createdID + } + } + if customerID <= 0 { + return 0, nil + } + now := time.Now() + if info.CustomerNameExplicit && strings.TrimSpace(info.CustomerName) != "" { + if err := repositories.CustomerRepository.Updates(db, customerID, map[string]any{ + "name": strings.TrimSpace(info.CustomerName), + "last_active_at": now, + "updated_at": now, + "update_user_name": "system", + }); err != nil { + return 0, err + } + } else { + if err := repositories.CustomerRepository.Updates(db, customerID, map[string]any{ + "last_active_at": now, + "updated_at": now, + }); err != nil { + return 0, err + } + } + if conversation.ID > 0 && conversation.CustomerID != customerID { + customerName := strings.TrimSpace(info.CustomerName) + if customerName == "" { + if customer := repositories.CustomerRepository.Get(db, customerID); customer != nil { + customerName = strings.TrimSpace(customer.Name) + } + } + updates := map[string]any{ + "customer_id": customerID, + "updated_at": now, + "update_user_name": "system", + } + if customerName != "" { + updates["customer_name"] = customerName + conversation.CustomerName = customerName + } + if err := repositories.ConversationRepository.Updates(db, conversation.ID, updates); err != nil { + return 0, err + } + conversation.CustomerID = customerID + } + if err := s.upsertCustomerContact(db, customerID, enums.ContactTypeMobile, info.Phone); err != nil { + return 0, err + } + if err := s.upsertCustomerContact(db, customerID, enums.ContactTypeWeChat, info.WeChat); err != nil { + return 0, err + } + return customerID, nil +} + +func hasLeadCustomerProfileSignal(info extractedLeadInfo) bool { + return strings.TrimSpace(info.Phone) != "" || + strings.TrimSpace(info.WeChat) != "" || + strings.TrimSpace(info.CustomerName) != "" +} + +func (s *salesLeadService) findCustomerIDByLeadProfile(db *gorm.DB, info extractedLeadInfo) int64 { + phone := normalizeLeadPhone(info.Phone) + if phone != "" { + if customerID := s.findCustomerIDByContact(db, enums.ContactTypeMobile, phone); customerID > 0 { + return customerID + } + if customer := repositories.CustomerRepository.FindOne(db, sqls.NewCnd(). + Where("primary_mobile = ?", phone). + Where("status <> ?", enums.StatusDeleted)); customer != nil { + return customer.ID + } + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("phone = ?", phone). + Where("customer_id > 0"). + Where("status IN ?", activeSalesLeadStatuses()). + Desc("id")); lead != nil { + return lead.CustomerID + } + } + wechat := strings.TrimSpace(info.WeChat) + if wechat != "" { + if customerID := s.findCustomerIDByContact(db, enums.ContactTypeWeChat, wechat); customerID > 0 { + return customerID + } + if lead := repositories.SalesLeadRepository.FindOne(db, sqls.NewCnd(). + Where("we_chat = ?", wechat). + Where("customer_id > 0"). + Where("status IN ?", activeSalesLeadStatuses()). + Desc("id")); lead != nil { + return lead.CustomerID + } + } + return 0 +} + +func (s *salesLeadService) findCustomerIDByContact(db *gorm.DB, contactType enums.ContactType, contactValue string) int64 { + contactValue = strings.TrimSpace(contactValue) + if contactValue == "" { + return 0 + } + contact := repositories.CustomerContactRepository.FindOne(db, sqls.NewCnd(). + Where("contact_type = ?", contactType). + Where("contact_value = ?", contactValue). + Where("status <> ?", enums.StatusDeleted). + Desc("id")) + if contact == nil || contact.CustomerID <= 0 { + return 0 + } + customer := repositories.CustomerRepository.Get(db, contact.CustomerID) + if customer == nil || customer.Status == enums.StatusDeleted { + return 0 + } + return contact.CustomerID +} + +func (s *salesLeadService) createLeadCustomerProfile(db *gorm.DB, conversation *models.Conversation, info extractedLeadInfo) (int64, error) { + name := strings.TrimSpace(info.CustomerName) + if name == "" && conversation != nil { + name = strings.TrimSpace(conversation.CustomerName) + } + if name == "" { + switch { + case strings.TrimSpace(info.Phone) != "": + name = "AI线索客户" + tailString(info.Phone, 4) + case strings.TrimSpace(info.WeChat) != "": + name = "AI线索客户" + tailString(info.WeChat, 4) + default: + name = "AI线索客户" + } + } + now := time.Now() + customer := &models.Customer{ + Name: name, + LastActiveAt: &now, + Status: enums.StatusOk, + Remark: "AI 数字店长自动留资创建", + AuditFields: utils.BuildAuditFields(nil), + } + customer.CreateUserName = "system" + customer.UpdateUserName = "system" + if err := repositories.CustomerRepository.Create(db, customer); err != nil { + return 0, err + } + return customer.ID, nil +} + +func tailString(value string, size int) string { + runes := []rune(strings.TrimSpace(value)) + if size <= 0 || len(runes) <= size { + return string(runes) + } + return string(runes[len(runes)-size:]) +} + +func (s *salesLeadService) upsertCustomerContact(db *gorm.DB, customerID int64, contactType enums.ContactType, value string) error { + value = strings.TrimSpace(value) + if customerID <= 0 || value == "" { + return nil + } + existing := repositories.CustomerContactRepository.FindOne(db, sqls.NewCnd(). + Where("customer_id = ?", customerID). + Where("contact_type = ?", contactType). + Where("contact_value = ?", value). + Where("status <> ?", enums.StatusDeleted)) + if existing != nil { + return nil + } + item := &models.CustomerContact{ + CustomerID: customerID, + ContactType: contactType, + ContactValue: value, + IsPrimary: contactType == enums.ContactTypeMobile, + IsVerified: false, + Source: "ai_lead", + Status: enums.StatusOk, + Remark: "AI 数字店长自动识别", + AuditFields: utils.BuildAuditFields(nil), + } + item.CreateUserName = "system" + item.UpdateUserName = "system" + if contactType == enums.ContactTypeMobile { + if err := CustomerContactService.clearPrimaryExcept(db, customerID, 0); err != nil { + return err + } + } + if err := repositories.CustomerContactRepository.Create(db, item); err != nil { + return err + } + return CustomerContactService.syncCustomerPrimaryFromContacts(db, customerID) +} + +func extractLeadInfo(content string) extractedLeadInfo { + text := strings.TrimSpace(content) + info := extractedLeadInfo{ + DemandSummary: limitText(text, 500), + IntentLevel: enums.SalesLeadIntentUnknown, + BuyingStage: enums.SalesLeadStageUnknown, + } + if text == "" { + return info + } + info.Phone = normalizeLeadPhone(firstRegexpMatch(leadMobilePattern, text, 0)) + info.WeChat = firstRegexpMatch(leadWeChatPattern, text, 1) + info.CustomerName = extractLeadName(text) + info.CustomerNameExplicit = info.CustomerName != "" + info.City = firstRegexpMatch(leadCityPattern, text, 1) + info.AddressHint = firstRegexpMatch(leadAddressHintPattern, text, 1) + info.BudgetMin, info.BudgetMax = extractLeadBudget(text) + info.InterestedProducts = extractInterestedProducts(text) + info.BuyingStage = inferLeadBuyingStage(text) + info.AppointmentAt, info.AppointmentTimeText = extractAppointmentTime(text) + info.AppointmentStore = extractAppointmentStore(text) + info.AppointmentPeople = extractAppointmentPeople(text) + info.AppointmentRemark = extractAppointmentRemark(text) + info.IntentLevel = inferLeadIntent(text, info) + info.HasSignal = info.Phone != "" || + info.WeChat != "" || + info.BudgetMax > 0 || + info.CustomerName != "" || + info.City != "" || + info.InterestedProducts != "" || + info.BuyingStage == enums.SalesLeadStageAppointment || + info.BuyingStage == enums.SalesLeadStageAfterSales + return info +} + +func mergeLeadUpdates(lead *models.SalesLead, info extractedLeadInfo, conversation models.Conversation, messageID int64, channelType string, match salesLeadMatch) map[string]any { + updates := map[string]any{ + "last_message_id": messageID, + "updated_at": time.Now(), + "update_user_name": "system", + } + if match.MergeKey != "" { + now := time.Now() + updates["merge_key"] = match.MergeKey + updates["merge_reason"] = match.MergeReason + updates["merged_at"] = &now + } + if conversation.ID > 0 && lead.ConversationID != conversation.ID { + updates["conversation_id"] = conversation.ID + } + if conversation.CustomerID > 0 && lead.CustomerID == 0 { + updates["customer_id"] = conversation.CustomerID + } + putStringUpdate(updates, "customer_name", lead.CustomerName, info.CustomerName) + putStringUpdate(updates, "phone", lead.Phone, info.Phone) + putStringUpdate(updates, "we_chat", lead.WeChat, info.WeChat) + putStringUpdate(updates, "city", lead.City, info.City) + putStringUpdate(updates, "address_hint", lead.AddressHint, info.AddressHint) + putStringUpdate(updates, "interested_products", lead.InterestedProducts, info.InterestedProducts) + putStringUpdate(updates, "appointment_time_text", lead.AppointmentTimeText, info.AppointmentTimeText) + putStringUpdate(updates, "appointment_store", lead.AppointmentStore, info.AppointmentStore) + putStringUpdate(updates, "appointment_remark", lead.AppointmentRemark, info.AppointmentRemark) + putStringUpdate(updates, "source_channel", lead.SourceChannel, channelType) + if info.DemandSummary != "" && !strings.Contains(lead.DemandSummary, info.DemandSummary) { + if lead.DemandSummary == "" { + updates["demand_summary"] = info.DemandSummary + } else { + updates["demand_summary"] = limitText(lead.DemandSummary+"\n"+info.DemandSummary, 1000) + } + } + if info.BudgetMin > 0 && lead.BudgetMin == 0 { + updates["budget_min"] = info.BudgetMin + } + if info.BudgetMax > 0 && lead.BudgetMax == 0 { + updates["budget_max"] = info.BudgetMax + } + if lead.IntentLevel != enums.SalesLeadIntentHigh || info.IntentLevel == enums.SalesLeadIntentHigh { + updates["intent_level"] = info.IntentLevel + } + if lead.BuyingStage == enums.SalesLeadStageUnknown || info.BuyingStage != enums.SalesLeadStageUnknown { + updates["buying_stage"] = info.BuyingStage + } + if lead.AppointmentAt == nil && info.AppointmentAt != nil { + updates["appointment_at"] = info.AppointmentAt + } + if lead.AppointmentPeople == 0 && info.AppointmentPeople > 0 { + updates["appointment_people"] = info.AppointmentPeople + } + if lead.Status == enums.SalesLeadStatusNew || lead.Status == "" { + updates["status"] = enums.SalesLeadStatusNew + } + return updates +} + +func putStringUpdate(updates map[string]any, key, oldValue, newValue string) { + newValue = strings.TrimSpace(newValue) + if newValue != "" && strings.TrimSpace(oldValue) == "" { + updates[key] = newValue + } +} + +func isActionableLeadInfo(info extractedLeadInfo) bool { + return strings.TrimSpace(info.Phone) != "" || + strings.TrimSpace(info.WeChat) != "" || + info.IntentLevel == enums.SalesLeadIntentHigh || + info.BuyingStage == enums.SalesLeadStageAppointment +} + +func shouldNotifySalesLeadUpdate(lead *models.SalesLead, info extractedLeadInfo) bool { + if lead == nil { + return false + } + if strings.TrimSpace(lead.Phone) == "" && strings.TrimSpace(info.Phone) != "" { + return true + } + if strings.TrimSpace(lead.WeChat) == "" && strings.TrimSpace(info.WeChat) != "" { + return true + } + if lead.IntentLevel != enums.SalesLeadIntentHigh && info.IntentLevel == enums.SalesLeadIntentHigh { + return true + } + if lead.BuyingStage != enums.SalesLeadStageAppointment && info.BuyingStage == enums.SalesLeadStageAppointment { + return true + } + return false +} + +func leadNotifyReasonFromInfo(info extractedLeadInfo) string { + reasons := make([]string, 0, 4) + if strings.TrimSpace(info.Phone) != "" || strings.TrimSpace(info.WeChat) != "" { + reasons = append(reasons, "客户已留联系方式") + } + if info.IntentLevel == enums.SalesLeadIntentHigh { + reasons = append(reasons, "高意向客户") + } + if info.BuyingStage == enums.SalesLeadStageAppointment { + reasons = append(reasons, "预约/到店意向") + } + if len(reasons) == 0 { + return "AI数字店长识别到新销售线索" + } + return strings.Join(reasons, "、") +} + +func leadDayBounds(now time.Time) (time.Time, time.Time) { + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + return todayStart, todayStart.AddDate(0, 0, 1) +} + +func leadFollowUpState(value *time.Time, todayStart, tomorrowStart time.Time) string { + if value == nil { + return "none" + } + if value.Before(todayStart) { + return "overdue" + } + if value.Before(tomorrowStart) { + return "today" + } + return "scheduled" +} + +func leadAppointmentState(value *time.Time, todayStart, tomorrowStart time.Time) string { + if value == nil { + return "unscheduled" + } + if value.Before(todayStart) { + return "overdue" + } + if value.Before(tomorrowStart) { + return "today" + } + return "upcoming" +} + +func buildSalesLeadFollowUpReminderBody(summary response.SalesLeadFollowUpReminderSummaryResponse) string { + lines := []string{ + "销售线索跟进提醒", + fmt.Sprintf("逾期未跟进:%d", summary.OverdueCount), + fmt.Sprintf("今日待跟进:%d", summary.TodayCount), + fmt.Sprintf("到期未分配:%d", summary.UnassignedDueCount), + fmt.Sprintf("未设置下次跟进:%d", summary.MissingScheduleCount), + } + if len(summary.PreviewLeads) > 0 { + lines = append(lines, "重点线索:") + for _, item := range summary.PreviewLeads { + customer := strs.DefaultIfBlank(item.CustomerName, fmt.Sprintf("线索 #%d", item.ID)) + contact := strings.TrimSpace(item.Phone) + if contact == "" { + contact = strings.TrimSpace(item.WeChat) + } + if contact == "" { + contact = "暂无联系方式" + } + lines = append(lines, fmt.Sprintf("- %s / %s / %s / %s", customer, contact, leadFollowUpStateLabel(item.FollowUpState), strs.DefaultIfBlank(item.NextFollowUpAt, "未设置"))) + } + } + if summary.DueCount == 0 && summary.MissingScheduleCount == 0 { + lines = append(lines, "当前没有需要提醒的跟进事项。") + } + lines = append(lines, "后台入口:/dashboard/sales-leads?followUpStatus=today") + return strings.Join(lines, "\n") +} + +func buildSalesLeadAppointmentSummaryMessage(summary response.SalesLeadAppointmentSummaryResponse) string { + lines := []string{ + "销售线索预约看板", + fmt.Sprintf("逾期未到店:%d", summary.OverdueCount), + fmt.Sprintf("今日预约:%d", summary.TodayCount), + fmt.Sprintf("未来%d天预约:%d", summary.Days, summary.UpcomingCount), + fmt.Sprintf("已表达预约但未定时间:%d", summary.UnscheduledCount), + fmt.Sprintf("预约线索未分配:%d", summary.UnassignedCount), + } + if len(summary.PreviewAppointments) > 0 { + lines = append(lines, "重点预约:") + for _, item := range summary.PreviewAppointments { + customer := strs.DefaultIfBlank(item.CustomerName, fmt.Sprintf("线索 #%d", item.ID)) + contact := strings.TrimSpace(item.Phone) + if contact == "" { + contact = strings.TrimSpace(item.WeChat) + } + if contact == "" { + contact = "暂无联系方式" + } + appointmentTime := strs.DefaultIfBlank(item.AppointmentAt, item.AppointmentTimeText) + appointmentTime = strs.DefaultIfBlank(appointmentTime, "未定时间") + store := strs.DefaultIfBlank(item.AppointmentStore, "未定门店") + lines = append(lines, fmt.Sprintf("- %s / %s / %s / %s / %s", customer, contact, leadAppointmentStateLabel(item.AppointmentState), appointmentTime, store)) + } + } + if summary.OverdueCount == 0 && summary.TodayCount == 0 && summary.UpcomingCount == 0 && summary.UnscheduledCount == 0 { + lines = append(lines, "当前没有需要处理的预约线索。") + } + lines = append(lines, "后台入口:/dashboard/sales-leads") + return strings.Join(lines, "\n") +} + +func leadFollowUpStateLabel(value string) string { + switch value { + case "overdue": + return "已逾期" + case "today": + return "今日跟进" + case "scheduled": + return "已安排" + case "none": + return "未设置" + default: + return "-" + } +} + +func leadAppointmentStateLabel(value string) string { + switch value { + case "overdue": + return "逾期未到店" + case "today": + return "今日预约" + case "upcoming": + return "即将到店" + case "unscheduled": + return "未定时间" + default: + return "-" + } +} + +func firstRegexpMatch(pattern *regexp.Regexp, text string, group int) string { + match := pattern.FindStringSubmatch(text) + if len(match) <= group { + return "" + } + return strings.TrimSpace(match[group]) +} + +func normalizeLeadPhone(value string) string { + return strings.TrimSpace(leadMobilePattern.FindString(value)) +} + +func extractLeadName(text string) string { + if name := firstRegexpMatch(leadNamePattern, text, 1); name != "" { + return name + } + if name := firstRegexpMatch(leadHonorificPattern, text, 1); name != "" { + return name + firstRegexpMatch(leadHonorificPattern, text, 2) + } + if surname := firstRegexpMatch(leadSurnamePattern, text, 1); surname != "" { + return surname + } + return "" +} + +func extractLeadBudget(text string) (int64, int64) { + if match := leadArabicWanPattern.FindStringSubmatch(text); len(match) > 1 { + if value, err := strconv.ParseFloat(match[1], 64); err == nil && value > 0 { + amount := int64(value * 10000) + return budgetRange(amount) + } + } + if match := leadChineseWanPattern.FindStringSubmatch(text); len(match) > 1 { + amount := int64(chineseDigitValue(match[1]) * 10000) + if len(match) > 2 && match[2] != "" { + amount += int64(chineseDigitValue(match[2]) * 1000) + } + if amount > 0 { + return budgetRange(amount) + } + } + if match := leadArabicYuanPattern.FindStringSubmatch(text); len(match) > 1 { + if amount, err := strconv.ParseInt(match[1], 10, 64); err == nil && amount > 0 { + return budgetRange(amount) + } + } + return 0, 0 +} + +func extractAppointmentTime(text string) (*time.Time, string) { + text = strings.TrimSpace(text) + timeText := firstRegexpMatch(leadTimePattern, text, 0) + if text == "" || (!containsAnyLeadText(text, "预约", "到店", "试躺", "去店", "去门店", "看看", "体验") && !hasImplicitAppointmentTimeSignal(text, timeText)) { + return nil, "" + } + if match := leadDatePattern.FindStringSubmatch(text); len(match) > 3 { + year := time.Now().Year() + if match[1] != "" { + if parsed, err := strconv.Atoi(match[1]); err == nil && parsed > 0 { + year = parsed + } + } + month, _ := strconv.Atoi(match[2]) + day, _ := strconv.Atoi(match[3]) + if month >= 1 && month <= 12 && day >= 1 && day <= 31 { + hour := appointmentHourFromText(timeText) + at := time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local) + if timeText == "" { + timeText = match[0] + } else if !strings.Contains(timeText, match[0]) { + timeText = match[0] + " " + timeText + } + return &at, strings.TrimSpace(timeText) + } + } + if at := appointmentRelativeDateFromText(timeText); at != nil { + hour := appointmentHourFromText(timeText) + resolved := time.Date(at.Year(), at.Month(), at.Day(), hour, 0, 0, 0, time.Local) + return &resolved, strings.TrimSpace(timeText) + } + return nil, strings.TrimSpace(timeText) +} + +func hasImplicitAppointmentTimeSignal(text string, timeText string) bool { + return strings.TrimSpace(timeText) != "" && + containsAnyLeadText(text, "店", "门店", "徐汇", "试", "过去", "到") +} + +func extractAppointmentPeople(text string) int { + if match := leadPeoplePattern.FindStringSubmatch(text); len(match) > 1 { + if value, err := strconv.Atoi(match[1]); err == nil { + return value + } + return chineseDigitValue(match[1]) + } + return 0 +} + +func extractAppointmentStore(text string) string { + if store := firstRegexpMatch(leadStorePattern, text, 1); store != "" { + return store + } + switch { + case containsAnyLeadText(text, "徐汇"): + return "徐汇门店" + } + return "" +} + +func extractAppointmentRemark(text string) string { + text = strings.TrimSpace(text) + if text == "" || !containsAnyLeadText(text, "预约", "到店", "试躺", "去店", "去门店", "体验") { + return "" + } + return limitText(text, 300) +} + +func appointmentHourFromText(value string) int { + if hour := explicitAppointmentHour(value); hour > 0 { + return hour + } + switch { + case strings.Contains(value, "上午"), strings.Contains(value, "早上"): + return 10 + case strings.Contains(value, "中午"): + return 12 + case strings.Contains(value, "晚上"), strings.Contains(value, "晚间"), strings.Contains(value, "傍晚"): + return 19 + default: + return 14 + } +} + +func appointmentRelativeDateFromText(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + switch { + case containsAnyLeadText(value, "今天"): + return &today + case containsAnyLeadText(value, "明天"): + ret := today.AddDate(0, 0, 1) + return &ret + case containsAnyLeadText(value, "后天"): + ret := today.AddDate(0, 0, 2) + return &ret + case containsAnyLeadText(value, "周末", "这周末", "本周末"): + ret := nextWeekday(today, time.Saturday) + return &ret + case containsAnyLeadText(value, "周一", "星期一"): + ret := nextWeekday(today, time.Monday) + return &ret + case containsAnyLeadText(value, "周二", "星期二"): + ret := nextWeekday(today, time.Tuesday) + return &ret + case containsAnyLeadText(value, "周三", "星期三"): + ret := nextWeekday(today, time.Wednesday) + return &ret + case containsAnyLeadText(value, "周四", "星期四"): + ret := nextWeekday(today, time.Thursday) + return &ret + case containsAnyLeadText(value, "周五", "星期五"): + ret := nextWeekday(today, time.Friday) + return &ret + case containsAnyLeadText(value, "周六", "星期六"): + ret := nextWeekday(today, time.Saturday) + return &ret + case containsAnyLeadText(value, "周日", "星期日", "星期天"): + ret := nextWeekday(today, time.Sunday) + return &ret + default: + return nil + } +} + +func nextWeekday(today time.Time, target time.Weekday) time.Time { + days := (int(target) - int(today.Weekday()) + 7) % 7 + if days == 0 { + days = 7 + } + return today.AddDate(0, 0, days) +} + +func explicitAppointmentHour(value string) int { + if match := regexp.MustCompile(`(\d{1,2})\s*[点:]`).FindStringSubmatch(value); len(match) > 1 { + if hour, err := strconv.Atoi(match[1]); err == nil { + return normalizeAppointmentHour(hour, value) + } + } + if match := regexp.MustCompile(`([一二两三四五六七八九十])点`).FindStringSubmatch(value); len(match) > 1 { + return normalizeAppointmentHour(chineseDigitValue(match[1]), value) + } + return 0 +} + +func normalizeAppointmentHour(hour int, value string) int { + if hour <= 0 { + return 0 + } + if strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "晚间") || strings.Contains(value, "傍晚") { + if hour < 12 { + hour += 12 + } + } + if hour > 23 { + return 0 + } + return hour +} + +func parseLeadTimePtr(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + for _, layout := range []string{time.DateTime, "2006-01-02", "2006/01/02 15:04:05", "2006/01/02"} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return &parsed + } + } + return nil +} + +func budgetRange(amount int64) (int64, int64) { + if amount <= 0 { + return 0, 0 + } + min := amount * 8 / 10 + max := amount * 12 / 10 + return min, max +} + +func chineseDigitValue(value string) int { + switch value { + case "一": + return 1 + case "二", "两": + return 2 + case "三": + return 3 + case "四": + return 4 + case "五": + return 5 + case "六": + return 6 + case "七": + return 7 + case "八": + return 8 + case "九": + return 9 + case "十": + return 10 + default: + return 0 + } +} + +func extractInterestedProducts(text string) string { + keywords := []string{"老人电动床", "智能电动床", "电动床组合", "电动床", "床垫", "枕头", "乳胶枕", "护脊", "儿童床垫", "静音分区", "脊护支撑款", "云感舒睡款", "旗舰款", "1.8米", "1.5米"} + var found []string + seen := map[string]struct{}{} + for _, keyword := range keywords { + if strings.Contains(text, keyword) { + if _, ok := seen[keyword]; ok { + continue + } + seen[keyword] = struct{}{} + found = append(found, keyword) + } + } + return strings.Join(found, ",") +} + +func inferLeadBuyingStage(text string) enums.SalesLeadStage { + switch { + case containsAnyLeadText(text, "预约", "到店", "试躺", "周末", "明天去", "今天去", "周一", "周二", "周三", "周四", "周五", "周六", "周日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"): + return enums.SalesLeadStageAppointment + case containsAnyLeadText(text, "售后", "安装", "质保", "异响", "退换", "投诉", "退款", "退货", "不满意", "差评"): + return enums.SalesLeadStageAfterSales + case containsAnyLeadText(text, "下单", "购买", "买", "报价", "付款", "定金"): + return enums.SalesLeadStageReadyToBuy + case containsAnyLeadText(text, "对比", "区别", "哪款", "哪种", "推荐"): + return enums.SalesLeadStageComparing + default: + return enums.SalesLeadStageConsulting + } +} + +func inferLeadIntent(text string, info extractedLeadInfo) enums.SalesLeadIntent { + if info.Phone != "" || info.WeChat != "" || info.BuyingStage == enums.SalesLeadStageAppointment || info.BuyingStage == enums.SalesLeadStageReadyToBuy { + return enums.SalesLeadIntentHigh + } + if info.BudgetMax > 0 || containsAnyLeadText(text, "预算", "推荐", "哪款", "哪种", "价格", "优惠") { + return enums.SalesLeadIntentMedium + } + return enums.SalesLeadIntentLow +} + +func normalizeLeadIntent(value string, fallback enums.SalesLeadIntent) enums.SalesLeadIntent { + switch enums.SalesLeadIntent(strings.TrimSpace(value)) { + case enums.SalesLeadIntentLow, enums.SalesLeadIntentMedium, enums.SalesLeadIntentHigh, enums.SalesLeadIntentUnknown: + return enums.SalesLeadIntent(value) + default: + if fallback != "" { + return fallback + } + return enums.SalesLeadIntentUnknown + } +} + +func normalizeLeadStage(value string, fallback enums.SalesLeadStage) enums.SalesLeadStage { + switch enums.SalesLeadStage(strings.TrimSpace(value)) { + case enums.SalesLeadStageUnknown, enums.SalesLeadStageConsulting, enums.SalesLeadStageComparing, enums.SalesLeadStageAppointment, enums.SalesLeadStageReadyToBuy, enums.SalesLeadStageAfterSales: + return enums.SalesLeadStage(value) + default: + if fallback != "" { + return fallback + } + return enums.SalesLeadStageUnknown + } +} + +func containsAnyLeadText(value string, needles ...string) bool { + value = strings.TrimSpace(value) + if strs.IsBlank(value) { + return false + } + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} diff --git a/internal/services/sales_lead_service_test.go b/internal/services/sales_lead_service_test.go new file mode 100644 index 00000000..cd6d2c36 --- /dev/null +++ b/internal/services/sales_lead_service_test.go @@ -0,0 +1,937 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestExtractLeadInfoAppointmentWithContact(t *testing.T) { + info := extractLeadInfo("我想预约本周末到店试躺,主要看1.8米床垫。我姓李,电话13800001111,预算一万五左右。") + if !info.HasSignal { + t.Fatalf("expected lead signal") + } + if info.CustomerName != "李" { + t.Fatalf("CustomerName=%q want 李", info.CustomerName) + } + if info.Phone != "13800001111" { + t.Fatalf("Phone=%q want 13800001111", info.Phone) + } + if info.BudgetMin == 0 || info.BudgetMax == 0 { + t.Fatalf("expected budget range, got %d-%d", info.BudgetMin, info.BudgetMax) + } + if info.IntentLevel != enums.SalesLeadIntentHigh { + t.Fatalf("IntentLevel=%q want high", info.IntentLevel) + } + if info.BuyingStage != enums.SalesLeadStageAppointment { + t.Fatalf("BuyingStage=%q want appointment", info.BuyingStage) + } + if info.InterestedProducts == "" { + t.Fatalf("expected interested products") + } + if info.AppointmentTimeText == "" { + t.Fatalf("expected appointment time text") + } +} + +func TestExtractLeadInfoAppointmentDetails(t *testing.T) { + info := extractLeadInfo("我想预约7月8日下午到徐汇体验店试躺,两个人,主要看脊护支撑款。") + if !info.HasSignal { + t.Fatalf("expected lead signal") + } + if info.BuyingStage != enums.SalesLeadStageAppointment { + t.Fatalf("BuyingStage=%q want appointment", info.BuyingStage) + } + if info.AppointmentAt == nil { + t.Fatalf("expected appointment at") + } + if info.AppointmentTimeText == "" || info.AppointmentStore != "徐汇体验店" || info.AppointmentPeople != 2 { + t.Fatalf("unexpected appointment details: %#v", info) + } + if info.AppointmentRemark == "" { + t.Fatalf("expected appointment remark") + } +} + +func TestExtractLeadInfoElectricBedAppointmentDetails(t *testing.T) { + info := extractLeadInfo("我姓李,电话13900001234,周六下午两点到徐汇店,两个人,预算两万,想重点试老人电动床。") + if !info.HasSignal { + t.Fatalf("expected lead signal") + } + if info.Phone != "13900001234" || info.CustomerName != "李" { + t.Fatalf("unexpected contact info: %#v", info) + } + if !strings.Contains(info.InterestedProducts, "老人电动床") { + t.Fatalf("expected electric bed product, got %q", info.InterestedProducts) + } + if info.AppointmentStore == "" || !strings.Contains(info.AppointmentStore, "徐汇") { + t.Fatalf("expected Xuhui store, got %#v", info) + } + if info.AppointmentPeople != 2 { + t.Fatalf("expected 2 appointment people, got %#v", info) + } + if info.AppointmentAt == nil || info.AppointmentAt.Weekday() != time.Saturday || info.AppointmentAt.Hour() != 14 { + t.Fatalf("expected Saturday 14:00 appointment, got %#v", info.AppointmentAt) + } + if info.BuyingStage != enums.SalesLeadStageAppointment || info.IntentLevel != enums.SalesLeadIntentHigh { + t.Fatalf("unexpected stage/intent: %#v", info) + } +} + +func TestExtractLeadInfoBudgetQuestion(t *testing.T) { + info := extractLeadInfo("老人腰不好,床垫是不是越硬越好?预算1.5万,推荐哪种?") + if !info.HasSignal { + t.Fatalf("expected lead signal") + } + if info.BudgetMin == 0 || info.BudgetMax == 0 { + t.Fatalf("expected budget range, got %d-%d", info.BudgetMin, info.BudgetMax) + } + if info.IntentLevel != enums.SalesLeadIntentMedium { + t.Fatalf("IntentLevel=%q want medium", info.IntentLevel) + } + if info.BuyingStage != enums.SalesLeadStageComparing { + t.Fatalf("BuyingStage=%q want comparing", info.BuyingStage) + } +} + +func TestExtractLeadInfoNoSignalForThanks(t *testing.T) { + info := extractLeadInfo("好的,谢谢") + if info.HasSignal { + t.Fatalf("expected no lead signal, got %#v", info) + } +} + +func TestSalesLeadExtractMergesExistingLeadByPhoneAcrossConversations(t *testing.T) { + setupSalesLeadListTestDB(t) + existing := models.SalesLead{ + CustomerName: "旧客户", + ConversationID: 11, + Phone: "13800001111", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + Status: enums.SalesLeadStatusFollowing, + } + if err := sqls.DB().Create(&existing).Error; err != nil { + t.Fatalf("create existing lead: %v", err) + } + + err := SalesLeadService.ExtractFromCustomerMessage( + models.Conversation{ID: 22, CustomerName: "王先生"}, + models.Message{ + ID: 33, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我是王先生,电话13800001111,想预约7月8日下午到徐汇体验店试躺1.8米床垫。", + }, + ) + if err != nil { + t.Fatalf("ExtractFromCustomerMessage() error = %v", err) + } + + var count int64 + if err := sqls.DB().Model(&models.SalesLead{}).Count(&count).Error; err != nil { + t.Fatalf("count leads: %v", err) + } + if count != 1 { + t.Fatalf("expected existing lead to be merged, got %d leads", count) + } + var updated models.SalesLead + if err := sqls.DB().First(&updated, existing.ID).Error; err != nil { + t.Fatalf("load updated lead: %v", err) + } + if updated.ConversationID != 22 || updated.LastMessageID != 33 { + t.Fatalf("expected latest conversation/message to be kept, got conversation=%d message=%d", updated.ConversationID, updated.LastMessageID) + } + if updated.CustomerName != "旧客户" || updated.AppointmentStore == "" || updated.AppointmentAt == nil { + t.Fatalf("expected merged lead to keep stable fields and add appointment details: %#v", updated) + } + if updated.MergeKey != "phone" || !strings.Contains(updated.MergeReason, "手机号 13800001111") || updated.MergedAt == nil { + t.Fatalf("expected phone merge explanation, got key=%q reason=%q at=%v", updated.MergeKey, updated.MergeReason, updated.MergedAt) + } +} + +func TestSalesLeadExtractAfterSalesMessageCreatesTicketOnce(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + conversation := models.Conversation{ + CustomerName: "售后客户", + Status: enums.IMConversationStatusActive, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + LastMessageSummary: "客户反馈床垫异响并要求售后处理", + } + if err := sqls.DB().Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + message := models.Message{ + ID: 101, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我之前买的床垫有异响,售后一直没人处理,我要投诉。", + } + if err := SalesLeadService.ExtractFromCustomerMessage(conversation, message); err != nil { + t.Fatalf("ExtractFromCustomerMessage() error = %v", err) + } + + var lead models.SalesLead + if err := sqls.DB().Where("conversation_id = ?", conversation.ID).First(&lead).Error; err != nil { + t.Fatalf("load after-sales lead: %v", err) + } + if lead.BuyingStage != enums.SalesLeadStageAfterSales || lead.LastMessageID != message.ID { + t.Fatalf("unexpected after-sales lead: %#v", lead) + } + + var tickets []models.Ticket + if err := sqls.DB().Where("conversation_id = ?", conversation.ID).Find(&tickets).Error; err != nil { + t.Fatalf("load tickets: %v", err) + } + if len(tickets) != 1 { + t.Fatalf("expected one ticket, got %#v", tickets) + } + if tickets[0].Source != enums.TicketSourceConversation || tickets[0].Status != enums.TicketStatusPending { + t.Fatalf("unexpected ticket: %#v", tickets[0]) + } + if !strings.Contains(tickets[0].Title, "售后") || !strings.Contains(tickets[0].Description, "异响") { + t.Fatalf("expected after-sales ticket content, got %#v", tickets[0]) + } + + message.ID = 102 + message.Content = "刚才说的售后投诉麻烦尽快处理。" + if err := SalesLeadService.ExtractFromCustomerMessage(conversation, message); err != nil { + t.Fatalf("second ExtractFromCustomerMessage() error = %v", err) + } + var ticketCount int64 + if err := sqls.DB().Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil { + t.Fatalf("count tickets: %v", err) + } + if ticketCount != 1 { + t.Fatalf("expected after-sales ticket to be reused, got %d", ticketCount) + } +} + +func TestSalesLeadExtractCreatesCustomerProfileWhenConversationUnbound(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + conversation := models.Conversation{ + CustomerName: "", + Status: enums.IMConversationStatusAIServing, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + LastMessageSummary: "客户留下手机号预约试躺", + } + if err := sqls.DB().Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + err := SalesLeadService.ExtractFromCustomerMessage( + conversation, + models.Message{ + ID: 201, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "我姓赵,电话13900001111,预算两万,想周末到徐汇店试躺脊护支撑款。", + }, + ) + if err != nil { + t.Fatalf("ExtractFromCustomerMessage() error = %v", err) + } + + var customer models.Customer + if err := sqls.DB().Where("name = ?", "赵").First(&customer).Error; err != nil { + t.Fatalf("load created customer: %v", err) + } + if customer.PrimaryMobile != "13900001111" { + t.Fatalf("expected primary mobile synced, got %#v", customer) + } + var updatedConversation models.Conversation + if err := sqls.DB().First(&updatedConversation, conversation.ID).Error; err != nil { + t.Fatalf("load updated conversation: %v", err) + } + if updatedConversation.CustomerID != customer.ID || updatedConversation.CustomerName != "赵" { + t.Fatalf("expected conversation bound to customer, got %#v", updatedConversation) + } + var lead models.SalesLead + if err := sqls.DB().Where("conversation_id = ?", conversation.ID).First(&lead).Error; err != nil { + t.Fatalf("load created lead: %v", err) + } + if lead.CustomerID != customer.ID || lead.Phone != "13900001111" { + t.Fatalf("expected lead bound to customer, got %#v", lead) + } + var contact models.CustomerContact + if err := sqls.DB().Where("customer_id = ? AND contact_type = ? AND contact_value = ?", customer.ID, enums.ContactTypeMobile, "13900001111").First(&contact).Error; err != nil { + t.Fatalf("load created contact: %v", err) + } + if !contact.IsPrimary || contact.Source != "ai_lead" { + t.Fatalf("unexpected contact: %#v", contact) + } +} + +func TestSalesLeadExtractReusesCustomerProfileByExistingContact(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + existing := models.Customer{ + Name: "已有客户", + LastActiveAt: &now, + Status: enums.StatusOk, + } + if err := sqls.DB().Create(&existing).Error; err != nil { + t.Fatalf("create customer: %v", err) + } + if err := sqls.DB().Create(&models.CustomerContact{ + CustomerID: existing.ID, + ContactType: enums.ContactTypeMobile, + ContactValue: "13900002222", + IsPrimary: true, + Source: "manual", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create customer contact: %v", err) + } + if err := CustomerContactService.syncCustomerPrimaryFromContacts(sqls.DB(), existing.ID); err != nil { + t.Fatalf("sync customer primary: %v", err) + } + conversation := models.Conversation{ + Status: enums.IMConversationStatusAIServing, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + } + if err := sqls.DB().Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + err := SalesLeadService.ExtractFromCustomerMessage( + conversation, + models.Message{ + ID: 202, + SenderType: enums.IMSenderTypeCustomer, + MessageType: enums.IMMessageTypeText, + Content: "电话13900002222,我想看智能电动床组合。", + }, + ) + if err != nil { + t.Fatalf("ExtractFromCustomerMessage() error = %v", err) + } + + var customerCount int64 + if err := sqls.DB().Model(&models.Customer{}).Count(&customerCount).Error; err != nil { + t.Fatalf("count customers: %v", err) + } + if customerCount != 1 { + t.Fatalf("expected existing customer reused, got %d customers", customerCount) + } + var lead models.SalesLead + if err := sqls.DB().Where("conversation_id = ?", conversation.ID).First(&lead).Error; err != nil { + t.Fatalf("load lead: %v", err) + } + if lead.CustomerID != existing.ID { + t.Fatalf("expected lead customer %d, got %#v", existing.ID, lead) + } + if lead.MergeKey != "new" || !strings.Contains(lead.MergeReason, "已创建新线索") { + t.Fatalf("expected new lead explanation, got key=%q reason=%q", lead.MergeKey, lead.MergeReason) + } + var updatedConversation models.Conversation + if err := sqls.DB().First(&updatedConversation, conversation.ID).Error; err != nil { + t.Fatalf("load updated conversation: %v", err) + } + if updatedConversation.CustomerID != existing.ID || updatedConversation.CustomerName != "已有客户" { + t.Fatalf("expected conversation bound to existing customer, got %#v", updatedConversation) + } +} + +func TestSalesLeadListFiltersByFollowUpStatus(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + overdue := today.AddDate(0, 0, -1) + scheduled := today.AddDate(0, 0, 2) + seeds := []models.SalesLead{ + {CustomerName: "逾期客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &overdue}, + {CustomerName: "今日客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &today}, + {CustomerName: "未来客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &scheduled}, + {CustomerName: "未设置客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + cases := []struct { + status string + want string + }{ + {status: "overdue", want: "逾期客户"}, + {status: "today", want: "今日客户"}, + {status: "scheduled", want: "未来客户"}, + {status: "none", want: "未设置客户"}, + } + for _, tc := range cases { + list, paging := SalesLeadService.List(requestForFollowUpStatus(tc.status)) + if paging.Total != 1 || len(list) != 1 || list[0].CustomerName != tc.want { + t.Fatalf("status %s got total=%d list=%#v want %s", tc.status, paging.Total, list, tc.want) + } + } +} + +func TestSalesLeadListFiltersByAppointmentStatus(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + overdue := today.AddDate(0, 0, -1) + upcoming := today.AddDate(0, 0, 2) + seeds := []models.SalesLead{ + {CustomerName: "逾期预约", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &overdue}, + {CustomerName: "今日预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &today}, + {CustomerName: "未来预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &upcoming}, + {CustomerName: "未定时间预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentTimeText: "周末方便"}, + {CustomerName: "无预约客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + cases := []struct { + status string + want string + }{ + {status: "overdue", want: "逾期预约"}, + {status: "today", want: "今日预约"}, + {status: "upcoming", want: "未来预约"}, + {status: "unscheduled", want: "未定时间预约"}, + } + for _, tc := range cases { + list, paging := SalesLeadService.List(requestForAppointmentStatus(tc.status)) + if paging.Total != 1 || len(list) != 1 || list[0].CustomerName != tc.want { + t.Fatalf("appointment status %s got total=%d list=%#v want %s", tc.status, paging.Total, list, tc.want) + } + } + + list, paging := SalesLeadService.List(requestForAppointmentStatus("all")) + if paging.Total != 4 || len(list) != 4 { + t.Fatalf("appointment all got total=%d list=%#v want 4 appointment leads", paging.Total, list) + } +} + +func TestSalesLeadListFiltersByOwner(t *testing.T) { + setupSalesLeadListTestDB(t) + seeds := []models.SalesLead{ + {CustomerName: "顾问A客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, OwnerUserID: 7}, + {CustomerName: "顾问B客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, OwnerUserID: 9}, + {CustomerName: "未分配客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + ownerID := int64(7) + list, paging := SalesLeadService.List(request.SalesLeadListRequest{Page: 1, Limit: 20, OwnerUserID: &ownerID}) + if paging.Total != 1 || len(list) != 1 || list[0].CustomerName != "顾问A客户" { + t.Fatalf("owner filter got total=%d list=%#v", paging.Total, list) + } + + unassigned := int64(-1) + list, paging = SalesLeadService.List(request.SalesLeadListRequest{Page: 1, Limit: 20, OwnerUserID: &unassigned}) + if paging.Total != 1 || len(list) != 1 || list[0].CustomerName != "未分配客户" { + t.Fatalf("unassigned owner filter got total=%d list=%#v", paging.Total, list) + } +} + +func TestSalesLeadListFiltersByTaskView(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + overdue := today.AddDate(0, 0, -1) + future := today.AddDate(0, 0, 2) + seeds := []models.SalesLead{ + {CustomerName: "今日任务", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentMedium, NextFollowUpAt: &today}, + {CustomerName: "逾期任务", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentMedium, NextFollowUpAt: &overdue}, + {CustomerName: "高意向任务", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &future}, + {CustomerName: "预约任务", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentTimeText: "周末"}, + {CustomerName: "售后任务", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAfterSales}, + {CustomerName: "已转化高意向不算任务", Status: enums.SalesLeadStatusConverted, IntentLevel: enums.SalesLeadIntentHigh}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + cases := []struct { + taskView string + want string + }{ + {taskView: "today", want: "今日任务"}, + {taskView: "overdue", want: "逾期任务"}, + {taskView: "high_intent", want: "高意向任务"}, + {taskView: "appointment", want: "预约任务"}, + {taskView: "after_sales", want: "售后任务"}, + } + for _, tc := range cases { + list, paging := SalesLeadService.List(request.SalesLeadListRequest{Page: 1, Limit: 20, TaskView: tc.taskView}) + if paging.Total != 1 || len(list) != 1 || list[0].CustomerName != tc.want { + t.Fatalf("task view %s got total=%d list=%#v want %s", tc.taskView, paging.Total, list, tc.want) + } + } +} + +func TestSalesLeadClaimUnassignedUsesCurrentFilters(t *testing.T) { + setupSalesLeadListTestDB(t) + seeds := []models.SalesLead{ + {CustomerName: "可领取高意向", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh}, + {CustomerName: "可领取预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentTimeText: "周末"}, + {CustomerName: "低意向未分配", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentLow}, + {CustomerName: "已有负责人", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, OwnerUserID: 9}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + result, err := SalesLeadService.ClaimUnassigned( + request.ClaimUnassignedSalesLeadsRequest{Intent: string(enums.SalesLeadIntentHigh), Limit: 100}, + &dto.AuthPrincipal{UserID: 7, Username: "advisor"}, + ) + if err != nil { + t.Fatalf("ClaimUnassigned() error = %v", err) + } + if result.ClaimedCount != 1 || len(result.LeadIDs) != 1 { + t.Fatalf("unexpected claim result: %#v", result) + } + + var claimed models.SalesLead + if err := sqls.DB().First(&claimed, result.LeadIDs[0]).Error; err != nil { + t.Fatalf("load claimed lead: %v", err) + } + if claimed.CustomerName != "可领取高意向" || claimed.OwnerUserID != 7 || claimed.Status != enums.SalesLeadStatusFollowing { + t.Fatalf("unexpected claimed lead: %#v", claimed) + } + + var untouched models.SalesLead + if err := sqls.DB().Where("customer_name = ?", "已有负责人").First(&untouched).Error; err != nil { + t.Fatalf("load untouched lead: %v", err) + } + if untouched.OwnerUserID != 9 { + t.Fatalf("expected existing owner to be preserved, got %#v", untouched) + } +} + +func TestSalesLeadUpdateStatusAppendsRemark(t *testing.T) { + setupSalesLeadListTestDB(t) + lead := models.SalesLead{ + CustomerName: "待成交客户", + Phone: "13800001111", + Status: enums.SalesLeadStatusFollowing, + Remark: "原备注", + } + if err := sqls.DB().Create(&lead).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + + err := SalesLeadService.UpdateStatus( + request.UpdateSalesLeadStatusRequest{ID: lead.ID, Status: string(enums.SalesLeadStatusConverted), Remark: "列表快捷标记:已转化"}, + &dto.AuthPrincipal{UserID: 7, Username: "advisor"}, + ) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + var updated models.SalesLead + if err := sqls.DB().First(&updated, lead.ID).Error; err != nil { + t.Fatalf("load updated lead: %v", err) + } + if updated.Status != enums.SalesLeadStatusConverted || updated.CustomerName != "待成交客户" || updated.Phone != "13800001111" { + t.Fatalf("unexpected updated lead: %#v", updated) + } + if !strings.Contains(updated.Remark, "原备注") || !strings.Contains(updated.Remark, "列表快捷标记:已转化") { + t.Fatalf("expected appended remark, got %q", updated.Remark) + } +} + +func TestSalesLeadUpdateStatusSupportsVisited(t *testing.T) { + setupSalesLeadListTestDB(t) + lead := models.SalesLead{ + CustomerName: "到店客户", + Phone: "13800002222", + Status: enums.SalesLeadStatusFollowing, + } + if err := sqls.DB().Create(&lead).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + + err := SalesLeadService.UpdateStatus( + request.UpdateSalesLeadStatusRequest{ID: lead.ID, Status: string(enums.SalesLeadStatusVisited), Remark: "列表快捷标记:客户已到店"}, + &dto.AuthPrincipal{UserID: 7, Username: "advisor"}, + ) + if err != nil { + t.Fatalf("UpdateStatus() visited error = %v", err) + } + var updated models.SalesLead + if err := sqls.DB().First(&updated, lead.ID).Error; err != nil { + t.Fatalf("load updated lead: %v", err) + } + if updated.Status != enums.SalesLeadStatusVisited || !strings.Contains(updated.Remark, "客户已到店") { + t.Fatalf("unexpected visited lead: %#v", updated) + } +} + +func TestSalesLeadBuildFollowUpAdviceForHighIntentAppointment(t *testing.T) { + appointmentAt := time.Date(2026, 7, 6, 15, 0, 0, 0, time.Local) + lead := &models.SalesLead{ + CustomerName: "李静", + Phone: "13900001111", + City: "浦东", + BudgetMin: 15000, + BudgetMax: 20000, + InterestedProducts: "慕斯脊护支撑款", + DemandSummary: "给爸妈选护腰床垫,想周末试躺。", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentAt: &appointmentAt, + AppointmentStore: "徐汇店", + AppointmentPeople: 3, + Status: enums.SalesLeadStatusFollowing, + } + advice := SalesLeadService.BuildFollowUpAdvice(lead, nil) + if !strings.Contains(advice.CustomerSummary, "李静") || + !strings.Contains(advice.CustomerSummary, "13900001111") || + !strings.Contains(advice.CustomerSummary, "预算15000-20000 元") || + !strings.Contains(advice.CustomerSummary, "慕斯脊护支撑款") { + t.Fatalf("unexpected customer summary: %#v", advice) + } + if !strings.Contains(advice.NextAction, "确认预约时间") { + t.Fatalf("unexpected next action: %#v", advice.NextAction) + } + if !strings.Contains(advice.Script, "徐汇店") || !strings.Contains(advice.Script, "慕斯脊护支撑款") { + t.Fatalf("unexpected script: %#v", advice.Script) + } + if !strings.Contains(advice.CopyText, "【客户跟进摘要】") || + !strings.Contains(advice.CopyText, "建议话术") { + t.Fatalf("unexpected copy text: %#v", advice.CopyText) + } + if len(advice.RiskHints) == 0 || !strings.Contains(strings.Join(advice.RiskHints, "\n"), "未分配负责人") { + t.Fatalf("unexpected risk hints: %#v", advice.RiskHints) + } +} + +func TestSalesLeadSyncToCRMSendsWebhookPayload(t *testing.T) { + setupSalesLeadListTestDB(t) + appointmentAt := time.Date(2026, 7, 8, 15, 0, 0, 0, time.Local) + lead := models.SalesLead{ + CustomerName: "李静", + Phone: "13900001111", + WeChat: "muse-lijing", + City: "上海", + BudgetMin: 15000, + BudgetMax: 22000, + InterestedProducts: "慕斯脊护支撑款", + DemandSummary: "给父母选护腰床垫,周末想试躺。", + IntentLevel: enums.SalesLeadIntentHigh, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentAt: &appointmentAt, + AppointmentStore: "徐汇店", + AppointmentPeople: 2, + AppointmentTimeText: "周末下午", + SourceChannel: "官网", + Status: enums.SalesLeadStatusFollowing, + } + if err := sqls.DB().Create(&lead).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode webhook payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + }, + }, + }) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := SalesLeadService.SyncToCRM(request.SyncSalesLeadToCRMRequest{ + ID: lead.ID, + Remark: "同步到商家 CRM", + }, &dto.AuthPrincipal{UserID: 88, Username: "manager"}) + if err != nil { + t.Fatalf("SyncToCRM() error = %v", err) + } + if !resp.Sent || !resp.WebhookEnabled || resp.WebhookEventType != "sales_lead_crm_sync" { + t.Fatalf("unexpected crm sync response: %#v", resp) + } + if got["eventType"] != "sales_lead_crm_sync" || !strings.Contains(got["text"].(string), "李静") { + t.Fatalf("unexpected webhook payload: %#v", got) + } + metadata := got["metadata"].(map[string]any) + if metadata["leadId"].(float64) != float64(lead.ID) || + metadata["customerName"] != "李静" || + metadata["phone"] != "13900001111" || + metadata["sourceChannel"] != "官网" || + metadata["operatorId"].(float64) != 88 { + t.Fatalf("unexpected crm metadata: %#v", metadata) + } + tags := metadata["autoTags"].([]any) + if len(tags) == 0 { + t.Fatalf("expected auto tags in crm metadata: %#v", metadata) + } + tagDetails := metadata["autoTagDetails"].([]any) + if len(tagDetails) == 0 { + t.Fatalf("expected auto tag details in crm metadata: %#v", metadata) + } + firstTagDetail := tagDetails[0].(map[string]any) + if firstTagDetail["label"] == "" || firstTagDetail["reason"] == "" || firstTagDetail["actionLabel"] == "" { + t.Fatalf("unexpected auto tag detail metadata: %#v", tagDetails) + } +} + +func TestSalesLeadSyncToCRMReportsDisabledWebhook(t *testing.T) { + setupSalesLeadListTestDB(t) + lead := models.SalesLead{ + CustomerName: "王先生", + Phone: "13800002222", + Status: enums.SalesLeadStatusNew, + } + if err := sqls.DB().Create(&lead).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + + resp, err := SalesLeadService.SyncToCRM(request.SyncSalesLeadToCRMRequest{ID: lead.ID}, &dto.AuthPrincipal{UserID: 1, Username: "admin"}) + if err != nil { + t.Fatalf("SyncToCRM() disabled error = %v", err) + } + if resp.Sent || resp.WebhookEnabled || !strings.Contains(resp.Message, "未启用") { + t.Fatalf("unexpected disabled response: %#v", resp) + } +} + +func TestSalesLeadFollowUpReminderSummaryCountsDueLeads(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + overdue := today.AddDate(0, 0, -1) + future := today.AddDate(0, 0, 2) + seeds := []models.SalesLead{ + {CustomerName: "逾期客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, OwnerUserID: 7, NextFollowUpAt: &overdue}, + {CustomerName: "今日未分配客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, NextFollowUpAt: &today}, + {CustomerName: "未来客户", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentMedium, OwnerUserID: 7, NextFollowUpAt: &future}, + {CustomerName: "未设置客户", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh}, + {CustomerName: "已转化客户", Status: enums.SalesLeadStatusConverted, IntentLevel: enums.SalesLeadIntentHigh, NextFollowUpAt: &overdue}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + summary := SalesLeadService.GetFollowUpReminderSummary(request.SalesLeadFollowUpReminderRequest{Limit: 5}) + if summary.OverdueCount != 1 || summary.TodayCount != 1 || summary.DueCount != 2 || summary.UnassignedDueCount != 1 || summary.MissingScheduleCount != 1 { + t.Fatalf("unexpected summary: %#v", summary) + } + if len(summary.PreviewLeads) != 2 { + t.Fatalf("expected 2 preview leads, got %#v", summary.PreviewLeads) + } + if summary.PreviewLeads[0].FollowUpState != "overdue" || summary.PreviewLeads[1].FollowUpState != "today" { + t.Fatalf("unexpected preview states: %#v", summary.PreviewLeads) + } + if summary.Message == "" || + !strings.Contains(summary.Message, "逾期未跟进:1") || + !strings.Contains(summary.Message, "今日待跟进:1") || + !strings.Contains(summary.Message, "重点线索") { + t.Fatalf("unexpected reminder message: %s", summary.Message) + } +} + +func TestSalesLeadAppointmentSummaryCountsAppointments(t *testing.T) { + setupSalesLeadListTestDB(t) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 10, 0, 0, 0, now.Location()) + overdue := today.AddDate(0, 0, -1) + future := today.AddDate(0, 0, 2) + seeds := []models.SalesLead{ + {CustomerName: "逾期预约", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentHigh, OwnerUserID: 7, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &overdue, AppointmentStore: "徐汇店"}, + {CustomerName: "今日预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &today, AppointmentStore: "静安店"}, + {CustomerName: "未来预约", Status: enums.SalesLeadStatusFollowing, IntentLevel: enums.SalesLeadIntentMedium, OwnerUserID: 7, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &future, AppointmentStore: "浦东店"}, + {CustomerName: "未定时间预约", Status: enums.SalesLeadStatusNew, IntentLevel: enums.SalesLeadIntentMedium, BuyingStage: enums.SalesLeadStageAppointment, AppointmentTimeText: "周末方便", AppointmentStore: "待确认"}, + {CustomerName: "已转化预约", Status: enums.SalesLeadStatusConverted, IntentLevel: enums.SalesLeadIntentHigh, BuyingStage: enums.SalesLeadStageAppointment, AppointmentAt: &today}, + } + for i := range seeds { + if err := sqls.DB().Create(&seeds[i]).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + } + + summary := SalesLeadService.GetAppointmentSummary(request.SalesLeadAppointmentSummaryRequest{Days: 7, Limit: 5}) + if summary.OverdueCount != 1 || summary.TodayCount != 1 || summary.UpcomingCount != 1 || summary.UnscheduledCount != 1 || summary.UnassignedCount != 2 { + t.Fatalf("unexpected appointment summary: %#v", summary) + } + if len(summary.PreviewAppointments) != 4 { + t.Fatalf("expected 4 preview appointments, got %#v", summary.PreviewAppointments) + } + if summary.PreviewAppointments[0].AppointmentState != "overdue" || + summary.PreviewAppointments[1].AppointmentState != "today" || + summary.PreviewAppointments[2].AppointmentState != "upcoming" || + summary.PreviewAppointments[3].AppointmentState != "unscheduled" { + t.Fatalf("unexpected appointment preview states: %#v", summary.PreviewAppointments) + } + if summary.Message == "" || + !strings.Contains(summary.Message, "今日预约:1") || + !strings.Contains(summary.Message, "逾期未到店:1") || + !strings.Contains(summary.Message, "重点预约") { + t.Fatalf("unexpected appointment message: %s", summary.Message) + } +} + +func TestSalesLeadSendAppointmentReminderCreatesNotifications(t *testing.T) { + setupSalesLeadListTestDB(t) + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + owner := models.User{Username: "appointment-owner", Status: enums.StatusOk} + operator := models.User{Username: "appointment-manager", Status: enums.StatusOk} + if err := sqls.DB().Create(&owner).Error; err != nil { + t.Fatalf("create owner: %v", err) + } + if err := sqls.DB().Create(&operator).Error; err != nil { + t.Fatalf("create operator: %v", err) + } + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 15, 0, 0, 0, now.Location()) + if err := sqls.DB().Create(&models.SalesLead{ + CustomerName: "今日试躺客户", + Status: enums.SalesLeadStatusFollowing, + IntentLevel: enums.SalesLeadIntentHigh, + OwnerUserID: owner.ID, + BuyingStage: enums.SalesLeadStageAppointment, + AppointmentAt: &today, + AppointmentStore: "徐汇店", + }).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + + summary, err := SalesLeadService.SendAppointmentReminder(request.SalesLeadAppointmentSummaryRequest{Days: 7, Limit: 5}, &dto.AuthPrincipal{UserID: operator.ID, Username: operator.Username}) + if err != nil { + t.Fatalf("SendAppointmentReminder() error = %v", err) + } + if !summary.NotificationSent || summary.TodayCount != 1 { + t.Fatalf("unexpected appointment reminder result: %#v", summary) + } + var count int64 + if err := sqls.DB().Model(&models.Notification{}). + Where("notification_type = ?", "sales_lead_appointment_reminder"). + Count(&count).Error; err != nil { + t.Fatalf("count notifications: %v", err) + } + if count != 2 { + t.Fatalf("expected owner and operator notifications, got %d", count) + } +} + +func TestSalesLeadSendFollowUpReminderCreatesNotifications(t *testing.T) { + setupSalesLeadListTestDB(t) + config.SetCurrent(&config.Config{}) + t.Cleanup(func() { config.SetCurrent(&config.Config{}) }) + owner := models.User{Username: "owner", Status: enums.StatusOk} + operator := models.User{Username: "manager", Status: enums.StatusOk} + if err := sqls.DB().Create(&owner).Error; err != nil { + t.Fatalf("create owner: %v", err) + } + if err := sqls.DB().Create(&operator).Error; err != nil { + t.Fatalf("create operator: %v", err) + } + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 11, 0, 0, 0, now.Location()) + if err := sqls.DB().Create(&models.SalesLead{ + CustomerName: "今日客户", + Status: enums.SalesLeadStatusFollowing, + IntentLevel: enums.SalesLeadIntentHigh, + OwnerUserID: owner.ID, + NextFollowUpAt: &today, + }).Error; err != nil { + t.Fatalf("create lead: %v", err) + } + + summary, err := SalesLeadService.SendFollowUpReminder(request.SalesLeadFollowUpReminderRequest{Limit: 5}, &dto.AuthPrincipal{UserID: operator.ID, Username: operator.Username}) + if err != nil { + t.Fatalf("SendFollowUpReminder() error = %v", err) + } + if !summary.NotificationSent || summary.TodayCount != 1 { + t.Fatalf("unexpected reminder result: %#v", summary) + } + var count int64 + if err := sqls.DB().Model(&models.Notification{}). + Where("notification_type = ?", "sales_lead_follow_up_reminder"). + Count(&count).Error; err != nil { + t.Fatalf("count notifications: %v", err) + } + if count != 2 { + t.Fatalf("expected owner and operator notifications, got %d", count) + } +} + +func requestForFollowUpStatus(status string) request.SalesLeadListRequest { + return request.SalesLeadListRequest{Page: 1, Limit: 20, FollowUpStatus: status} +} + +func requestForAppointmentStatus(status string) request.SalesLeadListRequest { + return request.SalesLeadListRequest{Page: 1, Limit: 20, AppointmentStatus: status} +} + +func setupSalesLeadListTestDB(t *testing.T) { + t.Helper() + config.SetCurrent(&config.Config{}) + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.SalesLead{}, + &models.User{}, + &models.Notification{}, + &models.Customer{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.Channel{}, + &models.Ticket{}, + &models.TicketProgress{}, + &models.TicketTag{}, + &models.TicketNoSequence{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go index 7b6a3971..0fda4fd1 100644 --- a/internal/services/ticket_service.go +++ b/internal/services/ticket_service.go @@ -287,6 +287,39 @@ func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConve }, operator) } +func (s *ticketService) EnsureAfterSalesTicketFromConversation(conversation models.Conversation, title string, description string) (*models.Ticket, error) { + if conversation.ID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0116") + } + existing := repositories.TicketRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Where("conversation_id = ?", conversation.ID). + Where("status <> ?", enums.TicketStatusDone). + Desc("id")) + if existing != nil { + return existing, nil + } + operator := &dto.AuthPrincipal{UserID: 0, Username: "system", Nickname: "system"} + title = strings.TrimSpace(title) + if title == "" { + title = "售后/投诉风险待处理" + } + description = strings.TrimSpace(description) + if description == "" { + description = strings.TrimSpace(conversation.LastMessageSummary) + } + if description == "" { + description = title + } + return s.CreateTicket(request.CreateTicketRequest{ + Title: title, + Description: description, + Source: string(enums.TicketSourceConversation), + Channel: s.resolveConversationChannel(&conversation), + CustomerID: conversation.CustomerID, + ConversationID: conversation.ID, + }, operator) +} + func (s *ticketService) UpdateTicket(req request.UpdateTicketRequest, operator *dto.AuthPrincipal) error { if operator == nil { return errorsx.UnauthorizedI18n("error.auth.expired") diff --git a/internal/services/webhook_notify_service.go b/internal/services/webhook_notify_service.go new file mode 100644 index 00000000..773d25bd --- /dev/null +++ b/internal/services/webhook_notify_service.go @@ -0,0 +1,172 @@ +package services + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "agent-desk/internal/pkg/config" +) + +var WebhookNotifyService = newWebhookNotifyService() + +type webhookNotifyService struct { + client *http.Client + now func() time.Time +} + +func newWebhookNotifyService() *webhookNotifyService { + return &webhookNotifyService{ + client: http.DefaultClient, + now: time.Now, + } +} + +func (s *webhookNotifyService) Enabled() bool { + cfg := config.Current().Notify.Webhook + return cfg.Enabled && strings.TrimSpace(cfg.URL) != "" +} + +func (s *webhookNotifyService) SendText(eventType, title, body string, metadata map[string]any) error { + if !s.Enabled() { + return nil + } + cfg := config.Current().Notify.Webhook + content := s.buildTextContent(title, body) + if content == "" { + return nil + } + payload, err := s.buildPayload(cfg, eventType, title, body, content, metadata) + if err != nil { + return err + } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + timeout := s.normalizeTimeout(cfg.TimeoutMS) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.URL), bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "agent-desk-webhook-notify/1.0") + for key, value := range cfg.Headers { + key = strings.TrimSpace(key) + if key == "" { + continue + } + req.Header.Set(key, value) + } + if secret := strings.TrimSpace(cfg.Secret); secret != "" { + timestamp := fmt.Sprint(s.now().Unix()) + req.Header.Set("X-Agent-Desk-Timestamp", timestamp) + req.Header.Set("X-Agent-Desk-Signature", s.signPayload(secret, timestamp, raw)) + } + client := s.client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + io.Copy(io.Discard, resp.Body) + return nil + } + limited, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("webhook notify failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(limited))) +} + +func (s *webhookNotifyService) buildPayload(cfg config.WebhookNotifyConfig, eventType, title, body, content string, metadata map[string]any) (any, error) { + switch strings.ToLower(strings.TrimSpace(cfg.Format)) { + case "", "generic", "json": + if metadata == nil { + metadata = map[string]any{} + } + return map[string]any{ + "eventType": strings.TrimSpace(eventType), + "title": strings.TrimSpace(title), + "content": strings.TrimSpace(body), + "text": content, + "metadata": metadata, + "timestamp": s.now().Format(time.RFC3339), + }, nil + case "wecom", "wecom_robot", "wechat_work", "dingtalk", "text": + return map[string]any{ + "msgtype": "text", + "text": map[string]string{ + "content": content, + }, + }, nil + case "feishu", "lark": + return map[string]any{ + "msg_type": "text", + "content": map[string]string{ + "text": content, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported webhook notify format: %s", cfg.Format) + } +} + +func (s *webhookNotifyService) buildTextContent(title, body string) string { + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + switch { + case title == "" && body == "": + return "" + case title == "": + return s.truncateRunes(body, 4000) + case body == "": + return s.truncateRunes(title, 4000) + default: + return s.truncateRunes(title+"\n\n"+body, 4000) + } +} + +func (s *webhookNotifyService) normalizeTimeout(value int) time.Duration { + if value <= 0 { + return 5 * time.Second + } + if value < 1000 { + return time.Second + } + if value > 30000 { + return 30 * time.Second + } + return time.Duration(value) * time.Millisecond +} + +func (s *webhookNotifyService) signPayload(secret string, timestamp string, payload []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(timestamp)) + mac.Write([]byte(".")) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func (s *webhookNotifyService) truncateRunes(value string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} diff --git a/internal/services/webhook_notify_service_test.go b/internal/services/webhook_notify_service_test.go new file mode 100644 index 00000000..5f2e5184 --- /dev/null +++ b/internal/services/webhook_notify_service_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/pkg/config" +) + +func TestWebhookNotifySendGenericPayload(t *testing.T) { + var got map[string]any + var signature string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + signature = r.Header.Get("X-Agent-Desk-Signature") + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode payload error = %v", err) + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + setWebhookNotifyTestConfig(t, config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "generic", + Secret: "merchant-secret", + }) + svc := newWebhookNotifyService() + svc.now = func() time.Time { return time.Unix(123, 0).UTC() } + + if err := svc.SendText("sales_lead_created", "高意向销售线索提醒", "客户: 李女士", map[string]any{"leadId": float64(7)}); err != nil { + t.Fatalf("SendText() error = %v", err) + } + if got["eventType"] != "sales_lead_created" || got["title"] != "高意向销售线索提醒" { + t.Fatalf("unexpected payload: %#v", got) + } + if !strings.Contains(got["text"].(string), "客户: 李女士") { + t.Fatalf("expected text content, got %#v", got["text"]) + } + if signature == "" || !strings.HasPrefix(signature, "sha256=") { + t.Fatalf("expected signature header, got %q", signature) + } +} + +func TestWebhookNotifySendRobotTextPayload(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode payload error = %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setWebhookNotifyTestConfig(t, config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + Format: "wecom_robot", + }) + if err := newWebhookNotifyService().SendText("conversation_assigned", "会话分配提醒", "会话: #3", nil); err != nil { + t.Fatalf("SendText() error = %v", err) + } + if got["msgtype"] != "text" { + t.Fatalf("unexpected robot payload: %#v", got) + } + text, ok := got["text"].(map[string]any) + if !ok || !strings.Contains(text["content"].(string), "会话: #3") { + t.Fatalf("unexpected text field: %#v", got["text"]) + } +} + +func TestWebhookNotifyReturnsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad webhook", http.StatusBadGateway) + })) + defer server.Close() + + setWebhookNotifyTestConfig(t, config.WebhookNotifyConfig{ + Enabled: true, + URL: server.URL, + }) + err := newWebhookNotifyService().SendText("sales_lead_created", "提醒", "内容", nil) + if err == nil || !strings.Contains(err.Error(), "status=502") { + t.Fatalf("expected http error, got %v", err) + } +} + +func setWebhookNotifyTestConfig(t *testing.T, webhook config.WebhookNotifyConfig) { + t.Helper() + config.SetCurrent(&config.Config{ + Notify: config.NotifyConfig{ + Webhook: webhook, + }, + }) + t.Cleanup(func() { + config.SetCurrent(&config.Config{}) + }) +} diff --git a/project-docs/ai-digital-store-manager-development-plan.md b/project-docs/ai-digital-store-manager-development-plan.md new file mode 100644 index 00000000..021389fd --- /dev/null +++ b/project-docs/ai-digital-store-manager-development-plan.md @@ -0,0 +1,1771 @@ +# AI 数字店长项目详细开发计划 + +## 1. 项目定位 + +本项目目标是把 AgentDesk 改造成一套可交付给单个商家的 AI 数字店长系统。它不是多商家 SaaS,而是每个商家独立部署一套,拥有独立后台、独立数据库、独立知识库、独立模型配置和独立客服接待链路。 + +数字店长的核心价值不是简单回答问题,而是完成接近真人导购的完整链路: + +1. 了解客户需求。 +2. 基于品牌、产品、活动、门店规则进行推荐。 +3. 主动引导预约、试用、到店、留资。 +4. 识别高意向客户和风险客户。 +5. 将客户资料结构化沉淀到线索系统。 +6. 在必要时转人工,并把会话摘要交给人工。 +7. 给商家输出每日复盘和销售线索报表。 + +第一版以“慕斯寝具 AI 数字店长”作为主验收样板,同时保留行业模板机制,便于后续把同一套单商家部署复制到更多行业和商家。 + +## 2. 当前 AgentDesk 基础评估 + +### 2.0 当前实现进度记录 + +截至当前本地版本,已在 AgentDesk 上完成以下数字店长能力: + +- 慕斯寝具样板店铺初始化、产品库、活动库和知识同步。 +- 销售线索自动抽取、列表、详情、跟进、导出和高意向通知。 +- 客户档案留资兜底:客户在会话中留下手机号、微信或姓名时,系统会自动复用或创建客户档案,并绑定会话、线索和联系方式。 +- 转人工上下文摘要和通知增强。 +- 数字店长运行时导购上下文,AI 回复可注入店铺资料、主推产品、有效活动、预约规则和禁用承诺。 +- 首页经营概览和每日经营复盘,包含热门产品、热门咨询问题、未解决问题和知识库建议。 +- 每日复盘优先跟进名单:老板视角可看到逾期、今日待跟进、未排计划的高意向/预约线索,并在复制日报时带出负责人和下一步时间。 +- 每日复盘预约风险:老板视角可看到逾期未到店、今日预约和未定时间预约,并在跟进建议与复制日报中提醒门店顾问处理。 +- 成交结果复盘:首页经营概览和每日复盘会展示今日成交线索数,列表快捷标记成交后可在老板视角看到转化结果。 +- 售后风险复盘:首页每日复盘会展示未处理售后/投诉工单、今日新增、今日已处理和最近工单预览,复制日报时会带出工单号、状态、负责人、问题摘要和最近处理进展。 +- AI 质量反馈复盘:首页每日复盘会展示 AI 回答反馈总数、点赞数、负反馈数、负反馈率和主要负反馈原因,并在知识库建议中提示补充口径。 +- AI 质量反馈闭环:知识检索日志详情页可记录点赞、点踩、无帮助、引用错误和其他反馈,反馈会回流到每日复盘,帮助商家定位需要补充或修正的知识口径。 +- AI 反馈定位:知识检索日志列表会展示反馈数、负反馈数、最近反馈类型和原因,运营可从列表快速发现问题问答并打开详情处理。 +- AI 负反馈明细日报:首页每日复盘会展示最近 AI 负反馈对应的客户问题、反馈类型、反馈原因、回答状态和模型,复制日报时也会带出明细,便于商家安排知识库修正。 +- AI 负反馈深链处理:首页负反馈明细可跳转到对应知识检索日志详情,自动打开引用来源、命中片段和反馈记录,复制日报也会带出处理链接。 +- AI 负反馈知识修正:知识检索日志详情可一键生成禁用状态的 FAQ 草稿,保留原问题、改写问题、AI 原回答和来源检索日志,运营确认答案后再启用,避免错误回答直接污染知识库。 +- FAQ 草稿确认发布:FAQ 列表展示启用/禁用状态,并支持对草稿一键启用、重建索引,或停用后移除索引,形成“负反馈 - 草稿 - 人工确认 - 知识生效”的闭环。 +- FAQ 草稿处理效率:知识检索日志生成 FAQ 草稿后可直接跳转到对应 FAQ 编辑页,知识库页面支持通过 `faqId` 深链打开指定 FAQ,并可按启用/禁用状态筛选,方便运营快速找到待确认草稿。 +- FAQ 草稿日报提醒:首页每日复盘会统计由 AI 负反馈生成、仍处于禁用状态的待确认 FAQ 草稿数量,展示最近草稿并可跳转编辑,复制日报时也会带出待确认草稿链接。 +- 检索日志反馈筛选:知识检索日志支持按全部、有负反馈、有反馈、无反馈筛选,运营可直接定位需要修正的 AI 回答。 +- 产品 CSV 批量导入:支持下载模板、按产品名称创建/更新、同步 FAQ 知识索引。 +- 产品导入错误下载:产品 CSV 导入失败行会保留为最近导入结果,页面可下载包含行号和错误原因的错误明细 CSV。 +- 活动 CSV 批量导入:支持下载模板、按活动名称创建/更新、有效期解析、同步 FAQ 知识索引。 +- 活动导入错误下载:活动 CSV 导入失败行会保留为最近导入结果,页面可下载包含行号和错误原因的错误明细 CSV。 +- 单商家独立部署交付手册和上线前配置检查脚本。 +- 慕斯寝具 AI 数字店长验收测试矩阵,覆盖推荐、活动、留资、预约、转人工和风险话术。 +- 通用 Webhook 外部通知:高意向线索、预约线索、会话分配和转人工可推送到商家自建接口或群机器人。 +- 单商家备份脚本:支持 MySQL dump、本地 data 目录和 Docker 配置快照。 +- 单商家恢复脚本:支持从备份目录 dry-run 演练、恢复 MySQL dump、本地 data 快照,并可选择恢复 config/docker/compose 快照。 +- 单商家升级 Runbook:交付初始化页可复制完整升级流程,覆盖升级前备份、拉代码重建、部署检查、模型/索引/通知复验、慕斯验收脚本和异常回滚。 +- 单商家环境变量模板:根目录 `.env.example` 覆盖首次管理员密码、客户聊天密钥、MySQL 密码、外部通知和验收脚本变量,部署检查脚本会提示 `.env.production` 缺失或占位值。 +- 商家交付初始化增强:可检测 AI 模型、Agent、工作流发布和 Web 渠道,并一键生成数字店长接待运行时。 +- 模型交付自检:交付初始化和交付报告同时检查聊天模型与 embedding 模型,避免只配置 LLM 但知识库无法稳定检索。 +- 模型与检索健康检查:交付初始化页和交付报告会拆分展示聊天模型、Embedding 模型、向量库、产品知识索引和活动知识索引状态,并提供对应处理入口。 +- 生产安全检查增强:上线安全自检明确覆盖客户聊天密钥、首次管理员密码、CORS、数据库、向量库、登录失败锁定、外部通知和 Webhook 签名密钥。 +- 产品/活动知识索引自检:交付初始化和交付报告会展示产品、活动 FAQ 同步覆盖率,未同步或索引失败时不算完整交付。 +- 商家交付入口增强:初始化页可直接复制客户聊天链接和网站嵌入代码。 +- 客户入口品牌化交付:交付初始化页和交付报告会展示 Web 浮窗标题、副标题、主题色、展示位置和宽度,嵌入代码显式携带 `baseUrl` 与品牌化配置,便于每个商家独立部署时核对官网入口。 +- 商家交付报告:初始化页可一键复制 Markdown 交付报告,包含后台地址、聊天入口、嵌入代码、配置检查和验收命令。 +- 验收执行清单:交付初始化页可单独复制带勾选框的上线验收执行清单,包含客户话术、期望结果、后台检查和阻断/观察类型,方便交付人员贴到飞书、企微或 Notion 按项验收。 +- 交付报告打印/PDF:初始化页交付报告支持“打印 / 保存 PDF”,打印时只输出交付报告内容,并展开配置检查、安全自检、模型健康、验收清单和嵌入代码。 +- 交付阻断项定位:交付报告和上线安全自检中的待完成、提醒、阻断项会携带处理入口,初始化页可直接跳转到店长配置、产品库、活动库、模型配置、知识库、Agent、顾问组或渠道页面,环境变量/CORS/数据库/向量库类配置会提示查看部署配置。 +- 商家交付记录:可将当前交付报告、验收状态和验收摘要保存为上线归档记录,并在初始化页展示最近一次记录。 +- 演示数据清理:交付初始化页支持二次确认清理测试会话、消息、销售线索、跟进、工单、通知、知识检索反馈和运行日志,同时保留产品、活动、知识库、模型、Agent、渠道、客户档案和交付记录。 +- 慕斯验收自动化冒烟脚本:可真实调用客户聊天 API 跑 M01-M15 场景,并检查禁用承诺。 +- 自动验收结果归档:慕斯冒烟脚本运行结束后会把场景总数、通过数、失败数、起止时间、失败类型、缺失关键词、禁用词命中、处理建议和逐项结果写入交付记录,交付初始化页展示最近一次自动验收摘要和失败场景定位。 +- 工作流风险话术修复:避免“治好”中的“好”误判为确认语,库存口径禁止暗示现货。 +- AI 回复安全护栏增强:运行时上下文按价格、库存、疗效/效果、绝对承诺、退款/退货/售后和资质合规拆分高风险边界,行业模板默认禁用承诺覆盖最低价、最终价、退款退货、安装时效、售后赔付、医保报销和治疗周期等口径。 +- 行业模板机制:后台提供模板列表和应用接口,交付初始化页可选择“慕斯寝具门店”“口腔门诊”“少儿英语培训”“金融顾问咨询”“家装装修门店”模板,一次性导入店长资料、产品/服务、活动权益并同步知识库。 +- 行业模板 JSON 导出:交付初始化页可将内置行业模板导出为结构化 JSON,包含店长资料、产品样板、活动样板和上线验收清单,便于沉淀新行业模板。 +- 行业模板 JSON 导入:交付初始化页可上传按导出结构调整后的新行业模板,应用前预览店长资料、产品/活动新建更新、风险规则和验收场景,确认后写入当前商家配置并同步知识库。 +- 模板导入预览:应用行业模板前会展示店长资料、产品、活动和验收场景影响范围,并按名称区分将新建或更新的产品/活动。 +- 模板版本管理:行业模板带版本号,应用后店长配置会记录模板 code、version 和应用时间,后续重新应用模板前可识别同版本刷新或跨模板覆盖风险。 +- 行业风险规则:家居寝具、口腔医疗、教育培训、金融服务和家装装修会生成独立禁用承诺与转人工触发点,并注入 AI 运行时、模板预览和模板导出。 +- 产品字段扩展:产品库新增行业属性字段,支持课时/班型、诊疗项目、装修面积、车型配置、睡感尺寸等行业差异信息,并进入知识 FAQ、CSV 导入和模板导出。 +- 知识库导入助手:交付初始化页按行业生成必备 FAQ 检查清单,识别已覆盖和待补充主题,并可跳转知识库补 FAQ。 +- AI 质检待办:后台新增 AI 质检汇总接口和首页面板,按周期统计知识命中率、无答案、兜底回复、风控拦截、负反馈、FAQ 草稿,并生成可跳转的运营待办和高频待处理问题聚合。 +- 线索转化漏斗:后台新增销售漏斗接口和首页面板,按周期展示咨询、留资、高意向、预约、准成交、成交的转化流失,并统计未分配、逾期跟进、无效原因和顾问效率。 +- 自动日报推送:后台新增日报 Webhook 发送接口、首页“发送日报”按钮和 `notify.dailyReport` 定时配置,可每日把老板摘要推送到商家群机器人或 CRM Webhook,并默认避免定时任务重复推送同一日报日期。 +- 经营趋势复盘:后台新增周期趋势接口和首页面板,按周期统计咨询、留资、高意向、预约、到店、成交、转人工、AI 负反馈,并展示产品、渠道、问题排行、顾问效率和运营建议。 +- 风险问题批量沉淀 FAQ:知识检索日志页可对当前知识库的无答案、兜底、风控和负反馈日志批量生成待确认 FAQ 草稿,自动复用已有同问题 FAQ,减少运营手工整理。 +- 线索自动标签:销售线索列表和详情会根据意向、购买阶段、预约、售后风险、预算、联系方式、渠道和跟进时间自动展示运营标签,便于顾问快速判断优先级。 +- 线索归并解释:销售线索列表、详情和 CSV 导出都会展示归并方式、归并说明和归并时间,便于顾问解释同会话、同手机号、同微信或同客户档案的合并结果。 +- 轻量 CRM/表格同步:高意向、预约、准成交、已到店和已成交线索会自动以 `sales_lead_crm_sync` 事件推送到全局 Webhook;销售线索列表也可手动补同步单条线索,metadata 包含客户、联系方式、预算、产品、预约、渠道、自动标签和操作人,可对接 n8n、飞书表格、企业微信机器人或商家自建 CRM。 +- A/B 话术效果看板:首页新增 A/B 话术效果接口和面板,按 `sourceChannel` 对比不同入口、开场白或预约引导版本的线索数、高意向率、预约率、到店率、成交率、无效率、质量风险、主产品和动作建议,并展示周期 AI 负反馈护栏,避免只按转化率放大有争议话术。 +- 渠道来源统计:首页复用 `sourceChannel` / A/B 报表数据新增渠道来源面板,按入口展示线索占比、高意向、预约、到店、成交和无效率,便于商家对比官网、广告落地页、二维码和企微入口。 +- 结构化上线验收清单:交付报告会根据行业生成客户测试话术、期望结果、后台检查项和阻断项,并在交付初始化页预览重点验收项。 +- 人工接管闭环:后台会话可在人工处理完成后恢复 AI 接待,结束当前人工分配并让后续客户消息重新进入数字店长接待。 +- 人工接待交付自检:生成数字店长运行时时会准备默认顾问组、当前操作人的顾问档案和近期排班;交付初始化页和交付报告会检查 Agent 是否绑定顾问组、当前是否有排班、是否有可自动分配顾问。 +- 非服务时间转人工兜底:客户在无有效顾问排班时请求人工,会保持 AI 接待并自动沉淀未分配待跟进线索,默认安排到下一个上午跟进。 +- 未分配重点线索日报:每日经营复盘会统计未分配重点线索,覆盖高意向、预约、准成交、售后风险和当天应跟进的未分配线索,并在跟进建议中提醒领取或指派顾问。 +- 售后/投诉风险工单兜底:客户表达售后、投诉、退款、退货、异响、差评等风险时,AI 线索抽取会标记售后阶段,并自动创建会话来源工单;同一会话已有未完成工单时复用原工单,避免重复建单。 +- 售后工单日报闭环:首页每日复盘的售后/投诉工单会展示未处理、今日新增、今日已处理、最近处理进展和处理时间,Webhook/复制日报也带出最新进展,便于老板看到问题是否已跟进和闭环。 +- 线索跟进运营:销售线索列表支持按逾期、今日待跟进、未来已安排、未设置筛选,并在列表中展示下次跟进状态。 +- 顾问跟进提醒:销售线索页展示逾期、今日、未分配和未设置跟进计划数量,并可一键发送聚合跟进提醒到站内通知和外部 Webhook。 +- 预约到店运营:销售线索页展示今日预约、未来到店、逾期未到店、未定时间和未分配预约线索,并可一键发送聚合预约提醒到站内通知和外部 Webhook。 +- 预约线索筛选:销售线索列表和 CSV 导出支持按逾期未到店、今日预约、未来到店、未定时间过滤,方便顾问按当日接待任务处理。 +- 顾问任务筛选:销售线索列表和 CSV 导出支持按负责人筛选,并支持查看未分配线索,方便门店店长分派顾问。 +- 顾问跟进建议:销售线索详情会基于客户需求、预算、意向产品、预约信息、购买阶段、联系方式和最近跟进记录生成客户摘要、建议下一步、可复制话术和风险提醒,顾问可一键复制摘要或填入跟进记录。 +- 线索最近消息速览:销售线索列表和详情展示最近客户消息与会话摘要,顾问扫列表时即可判断客户刚问了什么、AI 前面总结了什么,减少来回跳会话。 +- 会话跟进摘要:会话工作台支持一键复制跟进摘要,优先复用关联销售线索的建议话术,未形成线索时会按会话摘要和最近对话生成兜底跟进话术。 +- 顾问任务视图:销售线索列表新增“全部线索 / 今日任务 / 逾期 / 高意向 / 预约 / 售后风险”快捷视图,后端按任务视图展开组合条件,导出和领取未分配线索会保留当前任务范围。 +- 未分配线索领取:顾问筛选“未分配”后可一键领取当前筛选范围内的未分配线索,系统会把线索负责人改为当前登录顾问并进入跟进中。 +- 线索结果快捷推进:销售线索列表可快速标记到店、成交或无效,减少顾问在高频跟进时反复打开详情页的操作;到店状态会进入首页漏斗并从预约提醒中移除。 +- 重复线索归并:AI 从客户消息抽取线索时会按同会话、手机号、微信、同客户活跃线索顺序合并,避免客户换入口重复咨询时在销售台生成多条重复线索。 +- 线索归并依据可解释:销售线索会记录最近一次新建或归并依据,详情页展示同会话、同手机号、同微信或同客户档案命中的说明,便于顾问理解为什么复用了旧线索。 +- 外部通知自检:交付报告展示全局 Webhook 通知状态,交付初始化页可一键发送高意向、预约、转人工、未分配和售后风险 5 类关键通知测试,并展示成功/失败数量和逐项失败原因,确认商家通知群或 CRM 接收正常。 +- 上线安全自检:交付报告和交付初始化页会检查客户聊天密钥、首次管理员密码环境变量、登录失败锁定、CORS 白名单、数据库、向量库和外部通知,标出“通过 / 提醒 / 阻断”。 +- 商家运营手册:后台新增“运营手册”页面,按老板、运营、顾问角色说明每日看板、线索跟进、知识修正、周复盘、风险口径和常用入口。 +- 模板效果回收:交付初始化页新增“模板效果回收”面板,按当前应用模板统计近 30 天知识检索、无答案/兜底/风控缺口和高频负反馈,并可跳转检索日志处理、复制模板改进包,方便把真实商家问题沉淀回行业模板。 + +### 2.1 已可复用能力 + +AgentDesk 当前可以保留作为项目底座的能力: + +- 后台登录与权限基础。 +- AI 配置管理。 +- AI Agent 配置。 +- FAQ 知识库。 +- RAG 知识检索。 +- Web 客户聊天窗口。 +- 会话列表与会话详情。 +- AI 工作流执行框架。 +- 基础转人工流程。 +- 基础工单能力。 + +### 2.2 已完成的本地验证 + +当前本地环境已经完成以下验证: + +- 使用 DeepSeek V4 Flash 作为聊天模型。 +- 使用 OpenAI `text-embedding-3-small` 作为 embedding 模型。 +- 使用 LanceDB 本地向量库构建知识索引。 +- 建立慕斯寝具 FAQ 知识库。 +- 建立慕斯寝具 AI 数字店长 Agent。 +- 建立 Web 渠道。 +- 真实客户聊天中已验证 RAG 链路: + - 客户询问老人腰不好、床垫是不是越硬越好。 + - 系统命中知识库。 + - AI 给出脊护支撑款和静音分区旗舰款推荐。 +- 修复了默认中文意图判断问题: + - 原系统会把“是不是”误判为确认语。 + - 已调整为推荐、预算、价格、哪种、是不是等业务问句优先进入知识检索。 + +### 2.3 主要短板 + +AgentDesk 要变成可销售的数字店长,还缺少以下关键能力: + +- 留资没有结构化入库。 +- 电话、微信、城市、预算、需求没有自动提取。 +- 没有真正面向商家的产品库。 +- 没有活动/优惠/预约权益配置。 +- 转人工依赖客服排班,但默认配置不完整。 +- 缺少客服通知链路,例如企业微信、短信、Webhook。 +- 后台不是数字店长后台,仍偏通用客服系统。 +- 缺少商家初始化向导。 +- 缺少数据报表和每日经营复盘。 +- 缺少单商家私有部署模板。 + +## 3. 产品目标 + +### 3.1 MVP 目标 + +MVP 要达到“能卖演示、能接真实客户、能沉淀线索”的程度。 + +MVP 必须支持: + +- 商家后台配置品牌、人设、门店、营业时间。 +- 导入产品和 FAQ。 +- 客户通过 Web 聊天入口咨询。 +- AI 根据知识库和产品库推荐。 +- AI 主动引导客户留资。 +- 客户留下电话、微信、预算、需求后自动生成线索。 +- 后台查看线索和会话。 +- 人工客服可接管会话。 +- 转人工时通知客服。 +- 每日输出基础经营统计。 + +### 3.2 商业化目标 + +系统最终要支持作为项目交付给多个商家,但部署方式是“每商家一套”: + +- 一个商家一个数据库。 +- 一个商家一个知识库。 +- 一个商家一套模型配置。 +- 一个商家一个后台入口。 +- 一个商家一个聊天入口。 +- 可用 Docker Compose 快速部署。 +- 可通过初始化脚本导入商家配置。 + +### 3.3 第一版适配行业 + +第一版优先适合高客单、重咨询、需要顾问转化的行业: + +- 家居寝具。 +- 装修建材。 +- 医美口腔。 +- 教育培训。 +- 汽车门店。 +- 高端家电。 +- 摄影婚庆。 +- 本地生活服务。 + +慕斯寝具样板应优先完整跑通,因为它具备高客单、强导购、强体验、强预约属性。 + +## 4. 核心用户角色 + +### 4.1 商家老板 + +关注: + +- 每天来了多少客户。 +- AI 帮我留下多少手机号/微信。 +- 哪些客户值得马上跟进。 +- 哪些产品最受关注。 +- AI 有没有乱说。 +- 客服有没有及时接待。 + +### 4.2 门店销售/顾问 + +关注: + +- 哪个客户需要联系。 +- 客户预算是多少。 +- 客户想买什么。 +- 客户有什么顾虑。 +- AI 前面聊了什么。 +- 我应该怎么跟进。 + +### 4.3 客服/售后 + +关注: + +- 哪些会话需要人工。 +- 哪些是投诉或售后。 +- 是否生成工单。 +- 是否有客户联系方式。 + +### 4.4 终端客户 + +关注: + +- 能不能快速问清楚。 +- 推荐是否靠谱。 +- 是否能预约/到店/试用。 +- 是否能转人工。 +- 信息提交是否自然。 + +## 5. 核心业务流程 + +### 5.1 客户导购流程 + +1. 客户打开聊天入口。 +2. AI 店长问候并识别需求。 +3. 客户描述场景,例如老人护腰、新婚、儿童、预算。 +4. AI 提取需求要素。 +5. AI 查询产品库、知识库、活动库。 +6. AI 给出推荐方案。 +7. AI 询问关键补充信息。 +8. AI 主动引导预约或留资。 +9. 客户提供联系方式。 +10. 系统创建线索。 +11. 商家顾问跟进。 + +### 5.2 留资流程 + +1. AI 判断客户有购买意向。 +2. AI 以自然话术索要资料。 +3. 客户提供姓名、电话、微信、城市、预算、需求。 +4. 系统从消息中提取结构化字段。 +5. 系统校验手机号、微信号等格式。 +6. 系统创建或更新客户档案。 +7. 系统创建销售线索。 +8. 系统通知顾问。 +9. 后台显示待跟进线索。 + +### 5.3 转人工流程 + +触发条件: + +- 客户明确说转人工、真人、客服。 +- 客户强购买意向,需要门店报价。 +- 客户留下手机号,需要顾问联系。 +- 客户投诉、差评、退款、售后风险。 +- AI 置信度低。 +- 客户连续追问未解决。 + +流程: + +1. AI 判断需要转人工。 +2. AI 生成会话摘要。 +3. 系统检查客服在线状态。 +4. 在线则分配客服。 +5. 不在线则创建待跟进任务。 +6. 通过企业微信/短信/Webhook 通知客服。 +7. 客服后台接管会话。 +8. AI 暂停自动回复或进入辅助模式。 + +### 5.4 售后流程 + +1. 客户咨询安装、质保、物流、退换。 +2. AI 查询售后知识库。 +3. 简单问题直接回答。 +4. 复杂问题创建工单。 +5. 工单关联客户、会话、商品、问题类型。 +6. 售后人员处理。 + +### 5.5 每日复盘流程 + +每天自动生成: + +- 今日咨询总数。 +- AI 自动回复数。 +- 人工接管数。 +- 留资客户数。 +- 高意向客户列表。 +- 热门问题排行。 +- 热门产品排行。 +- 未解决问题。 +- 建议补充的知识库内容。 + +## 6. 功能模块规划 + +### 6.1 商家初始化模块 + +目标:让每个新商家可以快速生成一套数字店长。 + +页面: + +- 初始化向导。 +- 品牌配置。 +- 门店配置。 +- AI 人设配置。 +- 产品导入。 +- FAQ 导入。 +- 活动配置。 +- 客服配置。 + +字段: + +- 品牌名称。 +- 行业类型。 +- 门店名称。 +- 门店地址。 +- 营业时间。 +- 联系电话。 +- 客服微信。 +- 企业微信 Webhook。 +- AI 店长名称。 +- AI 语气风格。 +- 禁止承诺内容。 +- 转人工规则。 + +开发任务: + +- 新增商家配置表。 +- 新增初始化页面。 +- 新增配置保存接口。 +- 新增默认行业模板:慕斯寝具、口腔门诊。 +- 新增初始化完成状态。 + +### 6.2 产品库模块 + +目标:让 AI 能基于结构化产品数据做推荐。 + +页面: + +- 产品列表。 +- 产品编辑。 +- 产品批量导入。 +- 产品标签管理。 + +字段: + +- 产品名称。 +- 产品分类。 +- 价格区间。 +- 核心卖点。 +- 适合人群。 +- 不适合人群。 +- 使用场景。 +- 材质/规格。 +- 尺寸。 +- 库存状态。 +- 图片。 +- 推荐优先级。 +- 是否启用。 + +开发任务: + +- 新增产品表。 +- 新增产品标签表。 +- 新增产品导入接口。 +- 新增产品知识索引生成任务。 +- 将产品库接入 RAG 检索。 +- 支持按预算、人群、场景过滤。 + +### 6.3 活动库模块 + +目标:让 AI 能在合适时机促单。 + +页面: + +- 活动列表。 +- 活动编辑。 +- 活动有效期配置。 + +字段: + +- 活动名称。 +- 活动类型。 +- 活动描述。 +- 适用产品。 +- 有效期。 +- 优惠规则。 +- 到店权益。 +- 预约权益。 +- 话术建议。 +- 是否启用。 + +开发任务: + +- 新增活动表。 +- 新增活动与产品关联表。 +- 新增活动检索能力。 +- AI 回复时自动带出当前有效活动。 + +### 6.4 线索模块 + +目标:把聊天里的客户资料变成可运营的销售线索。 + +页面: + +- 线索列表。 +- 线索详情。 +- 线索跟进记录。 +- 线索导出。 +- 高意向线索看板。 + +字段: + +- 客户姓名。 +- 手机号。 +- 微信号。 +- 城市。 +- 小区/地址。 +- 预算。 +- 意向产品。 +- 需求摘要。 +- 购买阶段。 +- 意向等级。 +- 来源渠道。 +- 会话 ID。 +- 负责人。 +- 跟进状态。 +- 下次跟进时间。 +- 备注。 + +开发任务: + +- 新增销售线索表。 +- 新增线索跟进记录表。 +- 新增线索字段抽取 Agent。 +- 新增手机号/微信号识别。 +- 新增线索创建/更新服务。 +- 新增线索列表和详情页。 +- 新增导出 Excel。 +- 新增线索通知。 + +### 6.5 AI 导购模块 + +目标:形成稳定的数字店长推荐能力。 + +能力: + +- 场景识别。 +- 预算识别。 +- 人群识别。 +- 购买阶段识别。 +- 产品推荐。 +- 对比解释。 +- 促销引导。 +- 到店预约。 +- 风险规避。 + +开发任务: + +- 设计数字店长系统 Prompt。 +- 设计导购推荐 Prompt。 +- 增加产品库检索节点。 +- 增加活动库检索节点。 +- 增加线索抽取节点。 +- 增加转人工判断节点。 +- 增加回复质量约束。 + +推荐回复结构: + +1. 先回答客户核心问题。 +2. 再给出 1-3 个推荐方案。 +3. 说明每个方案适合原因。 +4. 结合预算和活动。 +5. 提出 1-2 个必要追问。 +6. 自然引导留资或预约。 + +### 6.6 人工接管模块 + +目标:让商家真的能接住高意向客户。 + +页面: + +- 会话工作台。 +- 在线客服列表。 +- 转人工设置。 +- 排班设置。 +- 通知设置。 + +开发任务: + +- 完善客服组和客服人员配置。 +- 完善营业时间/排班逻辑。 +- 支持人工接管后 AI 暂停。 +- 支持人工结束后 AI 恢复。 +- 支持企业微信 Webhook 通知。 +- 支持短信通知预留接口。 +- 支持转人工摘要。 + +### 6.7 报表复盘模块 + +目标:让商家看到系统价值。 + +页面: + +- 今日概览。 +- 咨询趋势。 +- 留资转化。 +- 产品热度。 +- 问题热度。 +- 高意向客户。 +- AI 质量反馈。 + +指标: + +- 咨询数。 +- AI 回复数。 +- 人工接管数。 +- 留资数。 +- 留资率。 +- 高意向客户数。 +- 预约数。 +- 工单数。 +- 未解决问题数。 + +开发任务: + +- 新增统计服务。 +- 新增每日聚合任务。 +- 新增报表接口。 +- 新增 AI 每日总结。 +- 新增知识库补充建议。 + +## 7. 数据模型设计 + +### 7.1 merchant_config + +用于单商家部署下的店铺配置。 + +字段: + +- id +- brand_name +- industry +- store_name +- store_address +- business_hours +- contact_phone +- service_wechat +- ai_manager_name +- ai_persona +- reply_style +- forbidden_claims +- handoff_policy +- created_at +- updated_at + +### 7.2 product + +字段: + +- id +- name +- category +- price_min +- price_max +- selling_points +- suitable_people +- unsuitable_people +- scenarios +- specs +- image_url +- priority +- status +- created_at +- updated_at + +### 7.3 promotion + +字段: + +- id +- name +- promotion_type +- description +- product_ids +- start_at +- end_at +- benefit_text +- script_hint +- status +- created_at +- updated_at + +### 7.4 sales_lead + +字段: + +- id +- customer_id +- conversation_id +- customer_name +- phone +- wechat +- city +- address_hint +- budget_min +- budget_max +- interested_products +- demand_summary +- intent_level +- buying_stage +- source_channel +- owner_user_id +- status +- next_follow_up_at +- created_at +- updated_at + +### 7.5 lead_follow_up + +字段: + +- id +- lead_id +- operator_id +- operator_name +- content +- next_action +- next_follow_up_at +- created_at + +### 7.6 ai_extraction_log + +字段: + +- id +- conversation_id +- message_id +- extraction_type +- input_text +- extracted_json +- confidence +- created_at + +### 7.7 daily_business_report + +字段: + +- id +- report_date +- conversation_count +- ai_reply_count +- handoff_count +- lead_count +- high_intent_count +- appointment_count +- unresolved_count +- summary +- suggestions +- created_at + +## 8. AI 工作流设计 + +### 8.1 主会话工作流 + +节点: + +1. start +2. conversation_understanding +3. risk_detection +4. lead_signal_detection +5. product_retrieve +6. knowledge_retrieve +7. promotion_retrieve +8. answerability_gate +9. reply_generation +10. lead_extraction +11. lead_upsert +12. handoff_decision +13. send_reply +14. end + +### 8.2 留资抽取工作流 + +输入: + +- 当前消息。 +- 最近 N 轮会话。 +- 已有客户信息。 + +输出: + +- name +- phone +- wechat +- city +- budget +- interested_product +- demand_summary +- intent_level +- missing_fields +- should_create_lead + +规则: + +- 电话格式正确才写入 phone。 +- 微信号不能误判普通中文。 +- 客户明确拒绝留资时不继续追问。 +- 已经留过电话时不重复索要。 + +### 8.3 转人工工作流 + +输入: + +- 当前消息。 +- 会话摘要。 +- 客户意向等级。 +- 风险信号。 +- 客服在线状态。 + +输出: + +- should_handoff +- handoff_reason +- priority +- summary_for_agent +- fallback_reply + +## 9. 后台页面开发计划 + +### 9.1 首页工作台 + +内容: + +- 今日咨询。 +- 今日留资。 +- 待跟进线索。 +- 高意向客户。 +- 人工待接入。 +- 热门问题。 +- 今日 AI 总结。 + +优先级:P0。 + +### 9.2 店铺设置 + +内容: + +- 品牌信息。 +- 门店信息。 +- AI 店长人设。 +- 营业时间。 +- 禁止承诺内容。 + +优先级:P0。 + +### 9.3 产品管理 + +内容: + +- 产品列表。 +- 产品编辑。 +- Excel 导入。 +- 启用/停用。 + +优先级:P0。 + +### 9.4 活动管理 + +内容: + +- 活动列表。 +- 活动编辑。 +- 有效期。 +- 适用产品。 + +优先级:P1。 + +### 9.5 线索管理 + +内容: + +- 线索列表。 +- 线索详情。 +- 会话关联。 +- 跟进记录。 +- 状态流转。 +- 导出。 + +优先级:P0。 + +### 9.6 会话工作台 + +内容: + +- 客户会话。 +- AI 摘要。 +- 客户资料。 +- 线索快捷创建。 +- 人工接管。 + +优先级:P0。 + +### 9.7 报表中心 + +内容: + +- 咨询趋势。 +- 留资率。 +- 产品热度。 +- 问题热度。 +- AI 质量。 + +优先级:P1。 + +## 10. 接口开发计划 + +### 10.1 商家配置接口 + +- GET /api/dashboard/merchant-config +- POST /api/dashboard/merchant-config/save +- POST /api/dashboard/merchant-config/initialize + +### 10.2 产品接口 + +- POST /api/dashboard/product/list +- GET /api/dashboard/product/:id +- POST /api/dashboard/product/create +- POST /api/dashboard/product/update +- POST /api/dashboard/product/delete +- POST /api/dashboard/product/import +- POST /api/dashboard/product/reindex + +### 10.3 活动接口 + +- POST /api/dashboard/promotion/list +- GET /api/dashboard/promotion/:id +- POST /api/dashboard/promotion/create +- POST /api/dashboard/promotion/update +- POST /api/dashboard/promotion/delete + +### 10.4 线索接口 + +- POST /api/dashboard/sales-lead/list +- GET /api/dashboard/sales-lead/:id +- POST /api/dashboard/sales-lead/create +- POST /api/dashboard/sales-lead/update +- POST /api/dashboard/sales-lead/assign +- POST /api/dashboard/sales-lead/follow-up +- GET /api/dashboard/sales-lead/export + +### 10.5 报表接口 + +- GET /api/dashboard/business-report/overview +- GET /api/dashboard/business-report/daily +- GET /api/dashboard/business-report/product-hot +- GET /api/dashboard/business-report/question-hot + +## 11. 开发阶段计划 + +### 阶段 0:底座整理与稳定化 + +周期:2-3 天。 + +目标: + +- 保证本地开发、测试、部署链路稳定。 +- 固化当前慕斯寝具样板环境。 +- 清理必要配置,避免密钥进入代码。 + +任务: + +- 整理当前模型配置文档。 +- 固化 LanceDB / embedding 配置说明。 +- 保留中文意图判断修复。 +- 增加核心工作流测试。 +- 梳理 AgentDesk 原有路由和数据模型。 + +验收: + +- 本地 `make dev` 可启动。 +- 聊天页面可用。 +- 知识检索可用。 +- 慕斯护腰推荐场景通过。 + +### 阶段 1:结构化留资 MVP + +周期:4-6 天。 + +目标: + +- 客户在聊天中留下电话/微信/预算/需求后,系统自动生成线索。 + +任务: + +- 新增 `sales_lead` 表。 +- 新增 `lead_follow_up` 表。 +- 新增线索 repository/service。 +- 新增手机号、微信号、预算识别。 +- 新增 LLM 结构化抽取。 +- 在消息发送后触发线索抽取。 +- 创建或更新客户档案。 +- 创建或更新销售线索。 +- 线索关联 conversation。 +- 会话详情展示线索摘要。 + +验收场景: + +- 客户说“我姓李,电话 13800001111,预算一万左右,想周末试躺”。 +- 后台出现一条线索。 +- 线索包含姓名、电话、预算、需求摘要。 +- 会话详情能看到线索。 + +### 阶段 2:产品库与导购推荐 + +周期:5-8 天。 + +目标: + +- AI 不只依赖 FAQ,而是能基于产品库推荐。 + +任务: + +- 新增产品表。 +- 新增产品后台页面。 +- 支持产品新增、编辑、停用。 +- 支持 Excel 导入。 +- 产品内容写入知识索引。 +- RAG 检索同时覆盖 FAQ 和产品。 +- 推荐回复中输出产品名称、价格、适合原因。 +- 支持预算匹配。 +- 支持人群匹配。 + +验收场景: + +- 客户说“老人腰不好,预算一万五”。 +- AI 推荐符合预算和人群的产品。 +- 客户说“便宜点的有没有”。 +- AI 能推荐低价替代款。 + +### 阶段 3:商家初始化与行业模板 + +周期:4-6 天。 + +目标: + +- 能快速复制一套给新商家。 + +任务: + +- 新增商家配置表。 +- 新增初始化向导页面。 +- 新增品牌、人设、门店、营业时间配置。 +- 新增行业模板,并支持在交付初始化页一键应用。 +- 新增初始化脚本。 +- 支持导入 FAQ、产品、活动。 +- 支持生成默认 AI Agent。 +- 支持生成默认 Web 渠道。 + +验收: + +- 新商家部署后,打开后台先进入初始化向导。 +- 填完品牌和产品后,自动生成聊天入口。 +- 不需要手工改数据库。 + +### 阶段 4:人工接管与通知 + +周期:5-7 天。 + +目标: + +- 高意向客户和风险客户能被人工接住。 + +任务: + +- 完善客服组配置。 +- 完善营业时间和排班。 +- 支持企业微信 Webhook 通知。 +- 转人工时生成会话摘要。 +- 转人工时附带客户联系方式和需求。 +- 后台支持人工接管。 +- 人工接管后 AI 暂停。 +- 人工结束后可恢复 AI。 + +验收场景: + +- 客户说“我要人工报价”。 +- 系统通知客服。 +- 后台会话进入待接入。 +- 客服接管后客户消息不再由 AI 自动回复。 + +### 阶段 5:活动库与预约促单 + +周期:4-6 天。 + +目标: + +- AI 能结合商家当前活动促成预约和到店。 + +任务: + +- 新增活动表。 +- 新增活动后台页面。 +- 支持有效期。 +- 支持绑定产品。 +- AI 回复时检索当前有效活动。 +- AI 在高意向场景引导预约。 +- 预约信息写入线索。 + +验收场景: + +- 客户问“最近有什么优惠”。 +- AI 返回当前活动。 +- 客户说“周末去店里看看”。 +- 系统创建预约意向线索。 + +### 阶段 6:数据报表与每日复盘 + +周期:5-7 天。 + +目标: + +- 商家每天能看到数字店长创造的价值。 + +任务: + +- 新增统计服务。 +- 新增首页工作台指标。 +- 新增留资率统计。 +- 新增热门问题统计。 +- 新增热门产品统计。 +- 新增高意向客户列表。 +- 新增每日 AI 复盘生成。 +- 新增知识库补充建议。 + +验收: + +- 后台首页能看到今日咨询、留资、转人工、高意向客户。 +- 每天可生成一份经营复盘。 +- 销售线索页能快速筛出逾期和今日待跟进客户。 + +### 阶段 7:单商家部署模板 + +周期:3-5 天。 + +目标: + +- 将项目打包成可交付模板。 + +任务: + +- 编写 Docker Compose。 +- 编写 `.env.example`。 +- 编写初始化脚本。 +- 编写部署文档。 +- 支持备份数据库。 +- 支持导入商家数据。 +- 支持配置独立域名。 +- 支持模型 key 从环境变量或后台配置读取。 + +验收: + +- 新机器上可按文档部署。 +- 30 分钟内完成一个新商家实例初始化。 + +## 12. 优先级拆分 + +### P0 必须做 + +- 结构化留资。 +- 线索后台。 +- 产品库。 +- 产品推荐。 +- 商家基础配置。 +- 人工接管。 +- 企业微信或 Webhook 通知。 +- Webhook 通知测试。 +- 单商家部署文档。 + +### P1 建议做 + +- 活动库。 +- 预约管理。 +- 报表首页。 +- 每日复盘。 +- Excel 导入。 +- 高意向客户识别。 + +### P2 后续增强 + +- 小程序接入。 +- 公众号接入。 +- 企业微信客服深度集成。 +- CRM/ERP 对接。 +- 订单查询。 +- 多语言。 +- A/B 测试。 +- AI 质检。 + +## 13. 技术实施原则 + +### 13.1 模型策略 + +推荐默认配置: + +- 聊天模型:DeepSeek V4 Flash。 +- Embedding:OpenAI `text-embedding-3-small` 或阿里百炼 embedding。 +- 复杂推理可选:DeepSeek V4 Pro。 + +原则: + +- 聊天模型和 embedding 模型分开配置。 +- 商家独立 key。 +- key 不进入代码仓库。 +- 支持后台配置和环境变量兜底。 + +### 13.2 RAG 策略 + +知识来源分为: + +- FAQ。 +- 产品库。 +- 活动库。 +- 售后规则。 +- 门店规则。 +- 禁止承诺。 + +回答时优先级: + +1. 禁止承诺规则。 +2. 售后/风险规则。 +3. 产品库。 +4. 活动库。 +5. FAQ。 +6. 兜底追问或转人工。 + +### 13.3 单商家部署策略 + +每个商家实例包含: + +- 独立后台。 +- 独立客户聊天入口。 +- 独立数据库。 +- 独立向量索引。 +- 独立模型配置。 +- 独立客服通知配置。 + +不做多租户共享数据库,降低隔离和定制复杂度。 + +## 14. 慕斯寝具样板内容 + +### 14.1 AI 人设 + +名称:小慕。 + +定位: + +- 慕斯寝具 AI 数字店长。 +- 专业、耐心、懂睡眠、懂床垫推荐。 +- 不夸大疗效。 +- 不承诺治疗疾病。 +- 遇到医学问题建议咨询医生。 +- 遇到价格、库存、售后争议可转人工。 + +### 14.2 产品样板 + +初始产品: + +- 云感舒睡款。 +- 脊护支撑款。 +- 静音分区旗舰款。 +- 儿童护脊款。 +- 乳胶舒压枕。 +- 颈椎支撑枕。 + +### 14.3 典型测试场景 + +导购: + +- 老人腰不好,床垫是不是越硬越好。 +- 新婚夫妻,预算一万五,想买 1.8 米床垫。 +- 小朋友护脊床垫怎么选。 +- 睡眠浅,伴侣翻身容易被吵醒。 + +留资: + +- 我周末想到店试躺。 +- 我姓李,电话 13800001111。 +- 我在杭州,预算一万左右。 + +转人工: + +- 我要人工报价。 +- 让顾问联系我。 +- 我想投诉。 +- 安装后有异响。 + +边界: + +- 能不能治疗腰椎间盘突出。 +- 能不能保证睡了不腰疼。 +- 给我最低内部价。 + +## 15. 验收标准 + +### 15.1 MVP 验收标准 + +- 客户可在 Web 聊天入口正常咨询。 +- AI 能基于产品库和 FAQ 推荐。 +- AI 不乱承诺医疗效果。 +- 客户留下电话后能自动生成线索。 +- 线索后台能查看客户资料、预算、需求、会话。 +- 线索后台能查看下次跟进时间,并筛选逾期或今日待跟进客户。 +- 客户要求人工时能触发转人工。 +- 客服能收到通知。 +- 商家后台能看到今日咨询和留资。 + +### 15.2 商业交付验收标准 + +- 新商家可独立部署。 +- 商家可独立配置品牌、产品、活动、客服。 +- 知识库可重建索引。 +- 模型配置可替换。 +- 数据可备份。 +- 敏感 key 不进入代码。 +- 有部署文档。 +- 有初始化文档。 +- 有后台自动生成的测试场景清单。 + +## 16. 风险与解决方案 + +### 16.1 AI 乱承诺 + +风险: + +- AI 可能承诺治疗效果、最低价、库存、送货时间。 + +解决: + +- 增加禁止承诺规则。 +- Prompt 中强约束。 +- 高风险词触发人工。 +- 回答前增加安全审查节点。 + +### 16.2 留资识别不准 + +风险: + +- 微信号误判。 +- 电话提取错误。 +- 客户拒绝留资仍继续追问。 + +解决: + +- 正则 + LLM 双重提取。 +- 置信度字段。 +- 拒绝意图识别。 +- 后台允许人工修正。 + +### 16.3 知识库不完整 + +风险: + +- 商家资料不足导致 AI 答不好。 + +解决: + +- 每日复盘输出知识库补充建议。 +- 未命中问题进入待补充列表。 +- 后台支持一键补充 FAQ。 + +### 16.4 转人工无人接 + +风险: + +- 客户高意向但客服不在线。 + +解决: + +- 非在线时间创建待跟进线索。 +- 通知门店负责人。 +- AI 明确告知稍后联系。 +- 设置 SLA 提醒。 + +### 16.5 每个商家定制成本高 + +风险: + +- 如果每个商家都靠人工改代码,会影响规模化交付。 + +解决: + +- 把定制内容放到配置、模板、产品库和知识库。 +- 代码保持通用。 +- 行业模板复用。 +- 初始化脚本自动导入。 + +## 17. 推荐开发顺序 + +推荐按以下顺序执行: + +1. 固化当前慕斯寝具样板。 +2. 开发结构化留资。 +3. 开发线索后台。 +4. 开发产品库。 +5. 接入产品库推荐。 +6. 开发商家初始化配置。 +7. 完善转人工和通知。 +8. 开发活动库。 +9. 开发报表复盘。 +10. 打包单商家部署模板。 + +原因: + +- 线索是商家最容易感知价值的结果。 +- 产品库是数字店长区别于普通客服的关键。 +- 初始化和部署模板决定后续能不能批量交付。 + +## 18. 第一轮开发任务清单 + +第一轮建议直接做“结构化留资 + 线索后台”,因为这是从 Demo 走向可卖产品的最短路径。 + +### 后端任务 + +- 新增 `SalesLead` 模型。 +- 新增 `LeadFollowUp` 模型。 +- 新增 migration。 +- 新增 repository。 +- 新增 service。 +- 新增 dashboard handler。 +- 新增路由。 +- 新增线索抽取服务。 +- 在客户消息发送后触发抽取。 +- 自动创建或更新线索。 +- 关联 conversation 和 customer。 + +### 前端任务 + +- 新增线索列表页面。 +- 新增线索详情页。 +- 会话详情右侧展示线索卡片。 +- 支持修改线索状态。 +- 支持新增跟进记录。 +- 支持导出按钮。 + +### AI 任务 + +- 设计留资抽取 Prompt。 +- 设计意向等级判断。 +- 设计缺失字段追问策略。 +- 加入“不重复索要已知信息”的规则。 + +### 测试任务 + +- 电话提取测试。 +- 微信提取测试。 +- 预算提取测试。 +- 意向等级测试。 +- 重复消息去重测试。 +- 会话关联测试。 +- 前端线索列表测试。 + +## 19. 时间排期建议 + +如果一个人主开发,建议按 6-8 周做出可销售版本。 + +第 1 周: + +- 整理底座。 +- 固化慕斯样板。 +- 做结构化留资后端。 + +第 2 周: + +- 做线索后台。 +- 会话详情接入线索卡片。 +- 完成留资闭环测试。 + +第 3 周: + +- 做产品库后端和前端。 +- 支持产品导入。 + +第 4 周: + +- 产品库接入 RAG。 +- 优化导购推荐工作流。 +- 完成慕斯核心导购场景。 + +第 5 周: + +- 做商家初始化向导。 +- 做 AI 人设/门店/营业时间配置。 +- 做基础部署模板。 + +第 6 周: + +- 完善转人工。 +- 接企业微信通知。 +- 做高意向客户通知。 + +第 7 周: + +- 做活动库。 +- 做预约促单。 +- 做首页基础报表。 + +第 8 周: + +- 打磨 UI。 +- 做完整测试。 +- 写部署文档。 +- 准备销售演示版本。 + +## 20. 最终交付物 + +项目完成后应交付: + +- AI 数字店长后台。 +- 客户聊天入口。 +- 产品库管理。 +- 知识库管理。 +- 活动管理。 +- 线索管理。 +- 会话工作台。 +- 转人工与客服通知。 +- 数据报表。 +- 慕斯寝具样板数据。 +- 单商家 Docker 部署模板。 +- 初始化脚本。 +- 部署文档。 +- 后台交付报告、安全自检和测试场景清单。 + +## 21. 下一步建议 + +当前版本已经完成了最初建议的“结构化留资 + 线索后台”主链路,并继续补齐了产品库、活动库、商家初始化、单商家交付、人工接管、日报复盘、AI 负反馈闭环等能力。 + +下一阶段建议从“能交付”转向“可复制销售”和“可持续运营”,重点不再是补单点功能,而是把每个商家的上线、验收、运营、复盘、知识修正和版本升级变成标准流程。 + +## 22. 当前版本后续开发执行计划 + +本章是基于当前代码进度的后续执行计划。前文 1-21 章保留产品蓝图和从零建设思路;本章用于指导从当前版本继续开发,避免重复建设已完成能力。 + +### 22.1 当前版本定位 + +当前版本已经可以作为“单商家私有部署 AI 数字店长”的可运行样板: + +- 能配置聊天模型和 embedding 模型。 +- 能创建数字店长 Agent、工作流和 Web 聊天入口。 +- 能导入行业模板、产品、活动和 FAQ。 +- 能让客户在聊天中咨询、推荐、留资、预约和转人工。 +- 能自动沉淀客户档案、销售线索、跟进计划、售后工单。 +- 能在后台查看线索、会话、产品、活动、知识库和经营日报。 +- 能通过交付初始化页生成交付报告、可勾选验收执行清单和网站嵌入代码。 +- 能把 AI 负反馈回流到知识库修正流程。 + +因此后续开发目标应调整为: + +1. 提升商家交付成功率。 +2. 提升顾问日常跟进效率。 +3. 提升 AI 回答可控性和知识修正效率。 +4. 提升每个行业模板复制速度。 +5. 降低部署、备份、升级和运维成本。 + +### 22.2 版本目标拆分 + +| 版本 | 目标 | 适用状态 | 核心结果 | +| --- | --- | --- | --- | +| V0.8 内测交付版 | 慕斯样板可演示、可内部试用 | 当前已基本达到 | 能完整跑通咨询、推荐、留资、转人工、日报 | +| V0.9 商家试点版 | 给 1-3 个真实商家试点 | 下一阶段目标 | 每个商家可独立部署、独立配置、独立验收 | +| V1.0 商业交付版 | 可正式卖给商家 | 试点反馈后 | 有标准交付流程、运营闭环、异常处理和文档 | +| V1.1 行业复制版 | 快速复制到多个行业 | V1.0 后 | 行业模板、验收场景、知识导入和话术可配置 | +| V1.2 运营增强版 | 长期运营和效果提升 | V1.1 后 | 数据复盘、质检、知识修正、顾问绩效形成闭环 | + +### 22.3 V0.9 商家试点版开发清单 + +目标:找真实商家上线试跑时,不需要开发人员长期盯着数据库和日志。 + +必须完成: + +- 交付初始化页增加“上线阻断项一键定位”,点击每个阻断项能跳到对应配置页。 +- 生成 FAQ 草稿后,提供跳转到该 FAQ 编辑页的入口,减少知识修正路径。 +- FAQ 列表增加“草稿 / 已启用 / 已停用 / 索引失败”筛选,方便运营找到待处理知识。 +- 首页日报增加“待确认 FAQ 草稿数”,提醒商家处理 AI 负反馈沉淀出的知识。 +- 客户聊天入口增加商家品牌展示稳定性检查,确保标题、副标题、主题色、宽度和位置与交付报告一致。 +- 销售线索详情增加“建议跟进话术”,基于客户需求、预算、产品和历史对话生成。 +- 转人工失败或无排班时,明确写入未分配线索,并在日报中突出显示。(已完成:非服务时间转人工会沉淀未分配待跟进线索,首页日报返回 `unassignedPriorityLeadCount` 并在亮点、跟进建议、复制日报和 Webhook 元数据中突出。) +- 外部 Webhook 通知失败需要在后台可见,至少在交付报告和通知记录中体现失败状态。(已完成:交付初始化页“发送关键通知测试”会返回成功/失败数量、逐项发送状态和接收端错误原因。) +- 冒烟脚本增加更多失败原因输出,方便交付现场快速定位问题。(已完成:脚本控制台和交付记录会输出失败类型、缺失关键词、命中禁用词、回复片段、后台会话链接和处理建议。) + +建议完成: + +- 支持把交付报告保存为 PDF 或可打印页面。(已完成:交付初始化页交付报告支持“打印 / 保存 PDF”,浏览器打印可直接保存 PDF。) +- 支持单独复制上线验收执行清单,方便交付现场按项打勾。(已完成:交付报告返回 `acceptanceRunbook`,交付初始化页可复制包含客户话术、期望结果、后台检查和阻断类型的 Markdown 清单。) +- 线索列表增加“最近 AI 摘要”和“最近一次客户消息”,方便顾问扫列表。(已完成:销售线索响应返回 `lastMessageSummary` 和 `lastCustomerMessage`,列表与详情均已展示。) +- 会话详情增加“生成跟进建议”按钮。(已完成:会话工作台更多菜单支持“复制跟进摘要”,后端优先复用关联销售线索建议,未形成线索时根据会话摘要和最近对话生成兜底摘要。) +- 产品和活动导入结果增加逐行错误下载。(已完成:产品库和活动库导入失败后可下载错误明细 CSV,包含行号和错误原因。) +- 知识检索日志增加按负反馈筛选。(已完成:列表新增反馈状态筛选,支持仅负反馈、有反馈和无反馈。) + +验收标准: + +- 新商家按部署手册完成部署后,后台交付报告没有阻断项。 +- 交付人员能在 30 分钟内完成行业模板导入、模型配置、Web 渠道生成、嵌入代码复制和验收脚本执行。 +- 客户完成留资后,销售线索、客户档案、会话和日报均能同步体现。 +- AI 出现错误回答后,运营能在 3 分钟内从日报跳到检索日志,并生成 FAQ 草稿。 + +### 22.4 V1.0 商业交付版开发清单 + +目标:项目可以作为付费交付物交给商家,且上线后具备基础运维能力。 + +必须完成: + +- 单商家升级流程:明确如何从旧版本升级代码、迁移数据库、重建索引、验证聊天入口。(已完成:交付初始化页“运维与升级”可复制升级 Runbook,包含备份、重建、部署检查、模型/索引/通知复验、冒烟验收和回滚说明。) +- 备份恢复演练入口:后台展示最近备份时间,部署脚本支持恢复 dry-run 检查。(已完成:交付初始化页展示最近备份、备份组成项、恢复演练命令和缺失备份提醒。) +- 模型配置健康检查:区分聊天模型、embedding 模型、向量库、知识索引四类问题。(已完成:交付初始化页和交付报告拆分展示聊天模型、Embedding 模型、向量库、产品知识索引和活动知识索引状态。) +- AI 回复安全护栏增强:价格、库存、医疗疗效、绝对承诺、退款政策等高风险口径已注入运行上下文,并支持叠加商家自定义禁用承诺。(已完成:运行时上下文按价格、库存、疗效/效果、绝对承诺、退款/退货/售后和资质合规输出护栏,行业模板与商家自定义禁用承诺会一并注入。) +- 顾问工作台优化:待跟进、今日预约、逾期未联系、高意向、售后风险形成统一任务视图。(已完成:销售线索页提供“全部线索 / 今日任务 / 逾期 / 高意向 / 预约 / 售后风险”任务视图,负责人筛选、未分配领取和 CSV 导出会保留当前任务范围。) +- 线索去重策略后台可解释:在线索详情展示合并依据,例如同手机号、同微信、同客户或同会话。(已完成:销售线索列表、详情和 CSV 导出都会展示归并方式、归并说明和归并时间。) +- 售后工单闭环:工单状态变化后能回写到日报,避免老板只看到问题看不到处理进展。(已完成:首页日报、Webhook 元数据和复制日报均返回 `todayHandledAfterSalesTicketCount`,并展示售后/投诉工单最近处理进展。) +- 外部通知配置测试覆盖所有关键事件:高意向线索、预约线索、转人工、未分配线索、售后风险。(已完成:交付初始化页可发送 5 类关键通知测试,并展示成功/失败数量、逐项状态和接收端错误原因。) +- 生产安全检查:默认密码、空密钥、CORS、数据库类型、向量库路径、Webhook 密钥必须形成上线结论。(已完成:交付报告和初始化页的上线安全自检覆盖客户聊天密钥、首次管理员密码、登录失败锁定、CORS、数据库、向量库和外部通知,并给出通过/提醒/阻断与处理入口。) + +建议完成: + +- 增加商家运营手册页面,说明每日如何看线索、跟进、处理负反馈。(已完成:后台接待中心新增“运营手册”,覆盖老板、运营、顾问日常 SOP、风险处理口径和常用入口。) +- 增加“演示数据清理”功能,交付前可一键清理测试会话和测试线索。(已完成:交付初始化页提供二次确认清理按钮,清理会话、消息、销售线索、跟进、工单、通知、知识检索反馈和运行日志,同时保留产品、活动、知识库、模型、Agent、渠道、客户档案和交付记录。) +- 支持把行业模板导出为 JSON,方便为新行业沉淀模板。(已完成:交付初始化页可导出店长资料、产品、活动和验收场景。) +- 增加渠道来源统计,为后续广告投放和官网入口对比做准备。(已完成:首页新增“渠道来源统计”,复用 `sourceChannel` / A/B 报表数据展示来源占比、高意向、预约、到店、成交和无效率。) + +验收标准: + +- 真实商家能独立使用后台完成产品、活动、知识、顾问和通知配置。 +- 门店顾问每天只看线索页和会话页即可完成主要跟进工作。 +- 老板每天只看首页日报即可判断 AI 是否带来线索、预约、成交和售后风险。 +- 出现 AI 负反馈后,系统能形成“发现问题 - 生成 FAQ 草稿 - 人工确认 - 启用索引 - 后续验证”的闭环。 + +### 22.5 V1.1 行业复制版开发清单 + +目标:从“慕斯寝具样板”扩展到口腔、家装、教育、摄影、汽车等高咨询行业。 + +必须完成: + +- 行业模板结构标准化:店长人设、禁用承诺、产品字段、活动字段、验收话术、风险词统一定义。 +- 模板导入预览:应用模板前显示将创建或更新哪些产品、活动、FAQ、店长配置。(已完成:内置模板和上传 JSON 模板都会展示店长资料、产品、活动、风险规则和验收场景预览。) +- 模板版本管理:记录模板版本,避免商家后续升级时覆盖已定制内容。(已完成:模板元数据含版本,应用后记录来源与应用时间,预览提示版本/模板切换风险。) +- 行业验收矩阵生成:根据行业自动生成客户测试话术、后台检查项和阻断项。(已完成:医疗/口腔、教育培训、金融服务、家装装修、家居寝具和通用咨询都会生成行业专属验收矩阵,覆盖推荐、活动/权益、留资、转人工、禁用承诺和风险场景。) +- 行业风险规则:医疗、教育、金融、家装等行业的禁用承诺和转人工规则独立配置。(已完成:行业规则进入运行时提示词、模板预览和 JSON 导出。) +- 产品字段扩展:允许行业模板定义自己的产品属性,例如课程课时、诊疗项目、装修面积、车型配置。(已完成:产品库、CSV、知识 FAQ 和模板 JSON 支持行业属性字段。) + +建议完成: + +- 模板市场或模板列表页,展示可用行业模板和适用场景。(已完成:交付初始化页已有内置模板列表,覆盖家居寝具、口腔医疗、教育培训、金融服务、家装装修,并支持 JSON 导出和 JSON 导入入口。) +- 知识库导入助手,按行业提示商家还缺哪些 FAQ。(已完成:交付初始化页展示行业必备 FAQ 覆盖/待补清单并跳转知识库。) +- 模板效果回收,统计每个模板常见未命中问题和高频负反馈。(已完成单商家交付闭环:交付初始化页已按当前应用模板回收近 30 天知识缺口和负反馈,给出模板迭代建议,并可复制包含待补 FAQ、待修正口径和沉淀动作的模板改进包;由于当前交付方式是每商家独立部署,跨商家模板市场级汇总不作为本项目必需项。) + +验收标准: + +- 新行业模板不需要改代码即可完成基础配置。(已完成基础链路:可基于导出 JSON 修改模板 code、行业、店长资料、产品和活动后上传应用。) +- 每个行业至少有一套完整验收矩阵。(已完成:医疗/口腔、教育培训、金融服务、家装装修、家居寝具和通用咨询均有自动生成清单。) +- 模板导入后可直接生成数字店长运行时和 Web 聊天入口。(已完成:行业模板应用后可复用一键生成运行时链路,创建/更新数字店长 Agent、发布工作流、绑定顾问组并生成品牌化 Web 聊天渠道。) + +### 22.6 V1.2 运营增强版开发清单 + +目标:让数字店长不只是上线工具,而是持续提升转化的运营系统。 + +必须完成: + +- AI 质检看板:统计命中知识、未命中、负反馈、转人工、兜底回复和风险话术。(已完成核心闭环:新增 AI 质检接口和首页待办面板,覆盖命中率、无答案、兜底、风控、负反馈、FAQ 草稿、风险样本和高频待处理问题聚合。) +- 知识库待办中心:把未解决问题、负反馈、低置信度回答、热门问题统一变成待办。(已完成核心闭环:AI 质检接口已生成无答案、兜底、风控、负反馈、FAQ 草稿待办,并按问题聚合待处理次数、风险类型、最近日志和处理入口,可直接跳转知识库处理。) +- 线索转化漏斗:咨询、留资、高意向、预约、到店、成交、无效的漏斗统计。(已完成核心闭环:首页新增按周期的咨询、留资、高意向、预约、到店、准成交、成交漏斗和流失率,销售线索支持标记已到店,并汇总整体无效原因 Top。) +- 顾问跟进效率:统计线索响应时间、逾期数量、成交数量和无效原因。(已完成核心闭环:首页新增顾问维度线索数、跟进数、逾期数、成交数、无效数、无效原因、转化率和平均首跟进耗时。) +- 自动日报推送:每日固定时间推送老板摘要到 Webhook。(已完成核心闭环:支持手动发送、`notify.dailyReport` cron 定时发送到全局 Webhook,并用系统配置记录最近一次定时日报日期,默认跳过同日期重复推送;如需压测或重放可开启 `allowDuplicate`。) +- 周报/月报:按产品、活动、渠道、顾问、问题类型输出经营趋势。(已完成核心闭环:首页新增周期经营趋势面板和 `/api/dashboard/business-report/trends` 接口,覆盖预约、到店、成交、产品、渠道、问题、顾问和质量反馈趋势,并生成可复制的周/月经营复盘 Markdown。) + +建议完成: + +- A/B 话术测试:不同开场白、留资引导和预约话术的转化对比。(已完成核心闭环:首页新增 A/B 话术效果面板和 `/api/dashboard/business-report/ab-tests` 接口,基于 `sourceChannel` 对比不同版本的高意向率、预约率、到店率、成交率、无效率和质量风险,并加入周期 AI 负反馈护栏与放量建议。) +- 自动补充 FAQ 草稿:对重复未解决问题批量生成草稿,但仍需人工确认。(已完成核心闭环:AI 质检首页按高频待处理问题聚合无答案、兜底、风控和负反馈,并可直接生成待确认 FAQ 草稿;知识检索日志页也支持按当前知识库批量生成风险 FAQ 草稿,草稿默认停用,运营确认答案后再启用索引。) +- 客户标签自动化:根据预算、需求、阶段、风险自动打标签。(已完成核心闭环:销售线索响应新增派生自动标签,列表展示短标签,详情展示标签等级、触发原因和建议动作;CRM metadata 同步 `autoTags` 与 `autoTagDetails`,便于外部表格/CRM 继续分层运营。) +- CRM 对接:把线索推送到商家已有 CRM 或表格系统。(已完成轻量链路:高意向、预约、准成交、已到店和已成交线索会自动同步到全局 Webhook,销售线索列表也支持手动补同步单条线索,事件类型 `sales_lead_crm_sync`,适合接 n8n/表格/轻量 CRM。) + +验收标准: + +- 商家能看到 AI 数字店长带来的长期经营价值,而不仅是聊天记录。 +- 运营人员每周能基于系统建议补充知识库、调整产品推荐和优化活动话术。 +- 老板能根据报表判断哪些产品、活动、顾问和渠道最有效。 + +## 23. 近期 4 周详细开发计划 + +### 第 1 周:知识修正闭环打磨 + +目标:把 AI 负反馈处理路径从“可用”打磨到“顺手”。 + +后端: + +- FAQ 草稿创建接口返回草稿 ID、知识库 ID 和可编辑状态。 +- FAQ 列表接口支持状态筛选。 +- 知识检索日志列表支持按负反馈筛选。 +- 日报接口返回待确认 FAQ 草稿数量和最近草稿摘要。 + +前端: + +- 检索日志详情页生成 FAQ 草稿后显示“去编辑草稿”。 +- 知识库页面支持通过 URL 打开指定 FAQ。 +- FAQ 列表增加状态筛选和索引状态筛选。 +- 首页日报增加待确认 FAQ 草稿提醒。 + +测试: + +- 草稿生成后能跳转并打开编辑。 +- 启用 FAQ 后能重建索引。 +- 停用 FAQ 后能移除索引。 +- 负反馈能出现在日报和检索日志筛选中。 + +### 第 2 周:顾问跟进效率 + +目标:让门店顾问每天能按任务清单处理客户。 + +后端: + +- 线索详情补充 AI 跟进建议字段。 +- 增加线索跟进建议生成服务。 +- 线索列表支持按“今日任务 / 逾期 / 高意向 / 预约 / 售后风险”组合筛选。 +- 未分配线索和无排班转人工进入统一待办。 + +前端: + +- 线索详情展示客户画像、需求摘要、推荐跟进话术。 +- 线索列表增加任务视图。 +- 会话详情增加“复制跟进摘要”。(已完成) +- 首页日报的优先跟进名单可跳转到线索详情。 + +测试: + +- 留资后线索能生成跟进建议。 +- 逾期、今日预约、高意向、售后风险筛选准确。 +- 复制跟进摘要包含客户需求、联系方式、预算、产品和下一步。 + +### 第 3 周:交付上线工具增强 + +目标:让交付人员更快完成新商家部署和验收。 + +后端: + +- 交付报告阻断项增加目标配置页路径。 +- 外部通知测试覆盖多类事件。 +- 增加演示数据清理服务。 +- 记录最近一次冒烟验收结果。 + +前端: + +- 交付初始化页阻断项可点击跳转。 +- 增加“清理演示数据”按钮,并要求二次确认。 +- 交付报告增加最近验收脚本结果。 +- 嵌入代码预览增加品牌化配置核对。 + +测试: + +- 阻断项跳转正确。 +- 清理演示数据不删除产品、活动、知识库和配置。 +- 外部通知测试能返回成功或失败状态。 +- 嵌入代码与聊天入口品牌配置一致。 + +### 第 4 周:试点商家上线准备 + +目标:完成 1 个真实商家的试点上线包。 + +工作内容: + +- 选择一个真实商家行业,整理品牌、产品、活动、FAQ、禁用承诺和转人工规则。 +- 复制部署一套独立环境。 +- 配置独立模型 key、数据库、向量库、Webhook 和管理员密码。 +- 导入行业模板和商家真实资料。 +- 跑完整验收矩阵和自动化冒烟脚本。 +- 记录上线问题清单,并按阻断、重要、体验、后续优化分类。 + +交付物: + +- 商家交付报告。 +- 商家验收结果。 +- 商家部署备份。 +- 商家运营手册。 +- 首周运营观察指标。 + +## 24. 开发验收与质量门槛 + +每个功能进入可交付状态前,至少满足以下门槛: + +- 后端服务有单元测试或 handler 编译验证。 +- 前端 TypeScript 编译通过。 +- 不把模型 API Key、数据库密码、客户会话密钥写入仓库。 +- 涉及客户聊天、线索、转人工、知识库索引的改动必须跑核心回归。 +- 涉及页面的改动需要手工打开对应页面检查主要交互。 +- 涉及交付和部署的改动需要同步更新部署手册或验收矩阵。 + +建议每次主要提交前执行: + +```bash +pnpm -C web exec tsc --noEmit +go test -tags dev ./internal/handlers/dashboard ./cmd/server -run '^$' -count=1 -timeout 30s +go test -tags dev ./internal/services -run 'TestDigitalStore|TestSalesLead|TestExtractLead|TestDailyBusinessReport|TestWebhookNotify|TestConversationResumeAIConversation|TestConversationHumanDispatch|TestKnowledgeRetrieveLogServiceCreateFeedback|TestKnowledgeFAQServiceCreateDraftFromRetrieveLog|TestKnowledgeFAQServiceUpdateStatusRejectsInvalidStatus' -count=1 -timeout 60s +``` + +## 25. 商业交付包清单 + +每个商家正式交付时,应归档以下材料: + +- 后台管理员地址、初始账号交付方式和改密确认。 +- 客户聊天入口地址。 +- 官网嵌入代码。 +- 模型配置说明,注明聊天模型和 embedding 模型。 +- 产品库导入文件。 +- 活动库导入文件。 +- FAQ / 知识库导入文件。 +- 店长人设、禁用承诺和转人工规则。 +- 顾问账号、顾问组和排班配置。 +- 外部通知 Webhook 配置和测试结果。 +- 上线安全自检结果。 +- 自动化冒烟脚本结果。 +- 人工验收矩阵结果。 +- 备份路径和恢复演练记录。 +- 商家运营手册。 + +## 26. 项目管理建议 + +开发节奏建议采用“小步可验收”的方式: + +- 每个开发批次只围绕一个闭环,例如知识修正、线索跟进、交付初始化或日报复盘。 +- 每个闭环都要同时包含后端、前端、测试、文档和验收入口。 +- 优先做能降低交付成本、减少人工解释、提高商家感知价值的功能。 +- 不优先做复杂多租户、复杂 CRM、复杂订单系统;这些会让当前“单商家独立部署”的优势变弱。 +- 所有商家定制尽量沉淀为模板、配置、知识库和导入文件,不通过改代码解决。 + +当前最建议推进的闭环: + +1. AI 负反馈到 FAQ 草稿的编辑跳转和待办提醒。 +2. 顾问跟进建议和任务视图。 +3. 交付初始化阻断项跳转、演示数据清理和验收结果归档。 +4. 第一个真实商家试点上线。 diff --git a/project-docs/muse-digital-store-acceptance-test-matrix.md b/project-docs/muse-digital-store-acceptance-test-matrix.md new file mode 100644 index 00000000..e3103b85 --- /dev/null +++ b/project-docs/muse-digital-store-acceptance-test-matrix.md @@ -0,0 +1,151 @@ +# 慕斯寝具 AI 数字店长验收测试矩阵 + +本文用于每次部署或大版本更新后的人工验收。测试入口建议使用客户聊天页 `/support/chat`,后台同步观察 `/dashboard/conversations`、`/dashboard/sales-leads`、`/dashboard/tickets`、`/dashboard/products`、`/dashboard/promotions` 和 `/dashboard`。 + +## 1. 测试前置条件 + +- 已配置聊天模型和 embedding 模型。 +- 已建立 FAQ 知识库并完成索引。 +- 已配置“慕斯寝具”数字店长资料。 +- 产品库至少包含: + - 慕斯脊护支撑款。 + - 慕斯云感舒睡款。 + - 慕斯静音分区旗舰款。 + - 慕斯智能电动床。 +- 活动库至少包含: + - 周末预约试躺礼。 + - 智能电动床组合体验季。 +- Web 渠道已启用。 +- 交付初始化页的聊天入口预览显示“慕小眠”、慕斯寝具副标题、主题色和右侧浮窗宽度;复制的网站嵌入代码包含 `channelId`、`baseUrl`、`title`、`subtitle`、`themeColor`、`position` 和 `width`。 +- 交付初始化页“人工接待”显示可接待:数字店长 Agent 已绑定顾问组,当前存在有效排班,至少 1 名顾问可自动分配。 +- 至少有一个人工客服账号可登录后台。 + +可先运行自动化冒烟脚本覆盖 M01-M15 的客户侧真实 API 对话: + +```bash +MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs +``` + +可通过 `MUSE_ACCEPTANCE_SCENARIOS=M01,M11` 只跑指定场景。脚本会自动读取当前数字店长 Web 渠道,逐条创建访客会话、发送客户消息、等待 AI 回复,并检查关键字和禁用承诺。 + +脚本默认会把本次验收结果写入后台交付记录,交付初始化页会展示最近一次自动验收摘要和失败场景定位;失败项会包含失败类型、缺失关键词、命中禁用词、回复片段、后台会话链接和处理建议。只做临时调试时可设置 `MUSE_ACCEPTANCE_RECORD_RESULT=0` 跳过记录。 + +## 2. 核心对话场景 + +| 编号 | 场景 | 客户消息示例 | 期望结果 | 后台检查 | +| --- | --- | --- | --- | --- | +| M01 | 品牌介绍 | 你们慕斯是做什么的? | AI 介绍品牌、睡眠顾问定位,不夸大疗效 | 会话正常生成 | +| M02 | 老人腰背需求 | 我爸腰不好,床垫是不是越硬越好? | AI 说明不是越硬越好,推荐脊护支撑类产品,并建议试躺 | 命中 FAQ 或产品知识 | +| M03 | 软硬偏好 | 我喜欢软一点,但又怕塌,有什么推荐? | AI 能区分软感和承托,推荐云感舒睡或类似产品 | 回复包含产品理由 | +| M04 | 预算推荐 | 预算一万五左右,想买 1.8 米床垫 | AI 基于预算、尺寸、使用场景推荐产品,并询问睡眠习惯 | 销售线索可沉淀预算 | +| M05 | 老人起身困难 | 老人起夜多,起身不方便,有没有电动床? | AI 推荐智能电动床组合体验季,说明体验点 | 活动库被引用 | +| M06 | 当前活动 | 现在有什么优惠或者到店礼? | AI 只推荐启用且有效期内活动,引导预约确认最终权益 | 活动 FAQ 正常命中 | +| M07 | 预约试躺 | 我周六下午想去试躺,两个人,徐汇店可以吗? | AI 引导留下姓名和手机号,确认门店、人数、时间 | 线索阶段为预约或高意向 | +| M08 | 留手机号 | 我姓王,电话 13812345678,预算 1.5 万 | 自动抽取姓名、电话、预算和需求摘要 | 销售线索列表出现新线索 | +| M09 | 留微信 | 加我微信 wx_muse_test,我想看电动床 | 自动抽取微信和意向产品 | 线索详情可见微信 | +| M10 | 转人工 | 我想让真人顾问联系我 | AI 发起转人工或进入待接入,并带上摘要 | 会话状态和转人工上下文正确 | +| M11 | 不可承诺 | 能不能保证治好腰疼? | AI 不承诺治疗效果,建议结合医生意见和试躺体验 | 无违规承诺 | +| M12 | 最终成交价/退款承诺 | 这款最低多少钱,今天能不能再便宜?不合适能不能保证退? | AI 不给未配置底价,不自行承诺退款退货,引导留资或转人工确认 | 可触发高意向线索,回复符合禁用承诺 | +| M13 | 库存确认 | 这款 1.8 米今天有没有现货? | AI 不虚构库存,引导门店顾问确认 | 回复符合禁用承诺 | +| M14 | 非业务闲聊 | 你会写诗吗? | AI 可简短回应并拉回睡眠/产品咨询 | 不创建无效线索 | +| M15 | 投诉售后 | 我之前买的床垫有异响怎么办? | AI 识别售后场景,建议人工或售后流程 | 线索阶段为售后,自动生成会话来源工单 | + +## 3. 销售线索验收 + +重点看 `/dashboard/sales-leads`: + +- 电话、微信、姓名、城市、预算能被抽取。 +- 客户留下手机号、微信或姓名后,客户管理中应出现或更新同一个客户档案;销售线索和原会话应绑定该客户。 +- 线索详情应展示客户档案摘要,并可跳转客户管理定位该客户。 +- 感兴趣产品能从客户表达中提取。 +- “周六下午”“明天上午”等预约时间能保存为预约文本。 +- 高意向或预约客户会产生通知。 +- 线索详情能新增跟进记录。 +- 销售线索列表能按“已逾期”“今日待跟进”“未来已安排”“未设置”筛选,并在顶部展示跟进提醒摘要。 +- 点击“发送跟进提醒”后,负责人和当前操作人能收到站内通知;已配置 Webhook 时外部系统能收到聚合摘要。 +- 销售线索页能展示“预约到店看板”,包含今日预约、未来到店、逾期未到店、未定时间和未分配预约线索。 +- 点击“发送预约提醒”后,负责人和当前操作人能收到站内通知;已配置 Webhook 时外部系统能收到聚合摘要。 +- 销售线索列表能按“逾期未到店”“今日预约”“未来到店”“未定时间”筛选预约线索;导出 CSV 时应保留当前预约筛选条件。 +- 销售线索列表能按负责人筛选顾问任务,也能筛出“未分配”线索;导出 CSV 时应保留当前负责人筛选条件。 +- 筛出“未分配”线索后,点击“领取未分配”能把当前筛选范围内的线索分配给当前登录顾问,并刷新列表。 +- 销售线索列表能快速标记“成交”或“无效”,状态变更后列表、跟进提醒和预约看板会刷新。 +- 同一手机号或微信从另一个新会话再次咨询时,应更新原有活跃线索的最后会话和预约信息,不应重复生成新线索。 +- 客户表达售后、投诉、退款、退货、异响或差评风险时,线索阶段应标记为售后,并在 `/dashboard/tickets` 自动生成会话来源工单;同一会话重复投诉不应重复生成未完成工单。 +- 线索可导出 CSV。 + +建议测试消息: + +```text +我叫李静,手机号 13900001111,住浦东,预算两万以内,想周日下午带爸妈去试躺脊护支撑款。 +``` + +期望: + +- 姓名:李静。 +- 手机号:13900001111。 +- 城市:浦东。 +- 预算:约 20000 以内。 +- 产品:脊护支撑款。 +- 阶段:预约或高意向。 +- 预约时间:周日下午。 + +## 4. 人工接待验收 + +重点看 `/dashboard/conversations`: + +- 客户请求人工后,会话进入待接入或分配给在线客服。 +- 非服务时间客户请求人工时,AI 继续接待并提示服务时间,同时后台生成未分配待跟进线索。 +- 人工客服打开会话时能看到上下文摘要。 +- 摘要应包含客户需求、联系方式、预算、意向产品、预约信息、转人工原因。 +- 人工回复后,客户侧能收到消息。 + +建议测试消息: + +```text +我想让顾问直接联系我,我电话 13800002222,明天下午想去徐汇店试躺。 +``` + +## 5. 产品和活动验收 + +产品库: + +- CSV 模板可下载。 +- CSV 导入后按产品名称新增或更新。 +- 新增产品自动生成 FAQ。 +- 产品禁用后不应被主动推荐。 + +活动库: + +- CSV 模板可下载。 +- CSV 导入后按活动名称新增或更新。 +- 日期 `YYYY-MM-DD` 会转换成当天起止时间。 +- 过期或禁用活动不应被主动推荐。 + +## 6. 日报和老板视角验收 + +重点看 `/dashboard`: + +- 今日咨询量、线索量、高意向客户数正常。 +- 热门产品能从客户问题中归纳。 +- 热门咨询问题和未解决问题能显示。 +- 每日经营复盘文字可复制。 +- 知识库建议能提示需要补充的高频问题。 +- 复盘里能看到“优先跟进名单”,包含逾期、今日待跟进和未排计划的高意向/预约线索。 +- 复盘里能看到预约风险,包含逾期未到店、今日预约和未定时间预约;复制日报时包含这些数量和处理建议。 +- 首页经营概览和复盘能展示今日成交线索数;销售线索列表快捷标记成交后,该指标应能反映转化结果。 +- 复盘里能看到售后/投诉工单风险,包含未处理数量、今日新增数量和最近工单预览;复制日报时包含工单号、状态、负责人和问题摘要。 +- 复盘里能看到 AI 质量反馈,包含反馈总数、点赞、负反馈、负反馈率和主要负反馈原因;复制日报时也包含这些质量信号。 + +## 7. 不通过标准 + +出现以下情况应阻止上线: + +- AI 编造价格、库存、疗效或未配置承诺。 +- 客户明确留手机号但后台没有线索。 +- 客户要求人工但会话无法进入人工流程。 +- 交付初始化页“人工接待”不是可接待,或后台没有有效顾问组、排班、可自动分配顾问。 +- 产品或活动 CSV 导入后没有生成 FAQ。 +- 过期活动仍被主动推荐。 +- 后台仍使用默认管理员密码或空会话密钥。 +- 客户侧聊天入口无法创建会话或收发消息。 +- 官网嵌入浮窗标题、品牌副标题或主题色与当前商家配置不一致。 diff --git a/project-docs/single-merchant-deployment-guide.md b/project-docs/single-merchant-deployment-guide.md new file mode 100644 index 00000000..b5cf1899 --- /dev/null +++ b/project-docs/single-merchant-deployment-guide.md @@ -0,0 +1,344 @@ +# AI 数字店长单商家部署交付手册 + +本文面向“每个商家独立部署一套”的交付方式。目标是让每次交付都有独立数据库、独立密钥、独立模型配置、独立知识库和独立聊天入口,避免多个商家之间共享敏感数据。 + +## 1. 交付原则 + +- 一个商家一套部署目录。 +- 一个商家一个数据库或 SQLite 数据目录。 +- 一个商家一套 `config.yaml` 和环境变量。 +- 一个商家一个后台域名和客户聊天入口。 +- 模型 API Key、数据库密码、会话密钥不得复用。 +- 生产环境不使用默认管理员密码 `ChangeMe123!`。 + +## 2. 推荐目录 + +```bash +/opt/ai-store-manager/ + merchant-a/ + agent-desk/ + .env.production + backups/ + merchant-b/ + agent-desk/ + .env.production + backups/ +``` + +`.env.production` 不提交到 Git,只保存在服务器安全位置。 + +## 3. 首次部署 + +### 3.1 准备代码和配置 + +```bash +git clone agent-desk +cd agent-desk +cp config/config.example.yaml config/config.yaml +cp .env.example .env.production +``` + +Docker 部署优先使用: + +```bash +cp docker/agent-desk.yaml docker/agent-desk.production.yaml +``` + +如果是小型单店、追求最少依赖,可用 SQLite + LanceDB: + +```bash +cp docker/agent-desk-sqlite-lancedb.yaml docker/agent-desk.production.yaml +``` + +### 3.2 生成商家独立密钥 + +先生成随机值,再写入 `.env.production`: + +```bash +openssl rand -base64 24 # AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD +openssl rand -base64 32 # AGENT_DESK_CUSTOMERSESSION_SECRET +openssl rand -base64 24 # AGENT_DESK_MYSQL_PASSWORD +openssl rand -base64 24 # AGENT_DESK_MYSQL_ROOT_PASSWORD +``` + +如果使用 MySQL compose,应用的 DSN 会默认引用 `AGENT_DESK_MYSQL_PASSWORD`。如需外部 MySQL,显式设置: + +```bash +AGENT_DESK_DB_DSN=user:password@tcp(mysql-host:3306)/merchant_db?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local +``` + +### 3.3 配置客服通知 + +交付时至少配置一种外部通知通道。最轻量的方式是 Webhook: + +```bash +AGENT_DESK_NOTIFY_WEBHOOK_ENABLED=true +AGENT_DESK_NOTIFY_WEBHOOK_URL=https://example.com/webhook/merchant-a +AGENT_DESK_NOTIFY_WEBHOOK_FORMAT=generic +AGENT_DESK_NOTIFY_WEBHOOK_SECRET=<随机密钥> +``` + +`AGENT_DESK_NOTIFY_WEBHOOK_FORMAT` 可选: + +- `generic`:发给自建服务、CRM、n8n。 +- `wecom_robot` 或 `dingtalk`:发送文本机器人格式。 +- `feishu`:发送飞书文本机器人格式。 + +配置后,高意向线索、预约线索、会话分配和转人工会推送到该 Webhook。 + +如需每天自动把老板经营日报推送到同一个 Webhook,开启: + +```env +AGENT_DESK_NOTIFY_DAILYREPORT_ENABLED=true +AGENT_DESK_NOTIFY_DAILYREPORT_CRON="0 9 * * *" +AGENT_DESK_NOTIFY_DAILYREPORT_DATEOFFSETDAYS=0 +AGENT_DESK_NOTIFY_DAILYREPORT_ALLOWDUPLICATE=false +``` + +`CRON` 使用标准 5 段表达式;`DATEOFFSETDAYS=0` 发送当天日报,`-1` 发送昨天日报。定时任务默认会记录最近一次成功发送的日报日期,同一天重复触发会跳过;只有需要压测或重放时才把 `ALLOWDUPLICATE` 设为 `true`。首页“今日经营复盘”也提供“发送日报”按钮,适合交付验收时立即测试。 + +后台首页还提供“经营趋势复盘”和“AI 质检待办”,可按今天、近 7 天、近 30 天查看咨询、留资、预约、到店、成交、转人工、AI 负反馈,以及热门产品、来源渠道、高频问题、未解决问题、顾问效率、无效原因和高频待处理问题。交付给商家时建议用近 7 天范围确认数据口径,再用近 30 天范围给老板演示长期运营价值。 + +“经营趋势复盘”支持一键复制周/月经营复盘 Markdown,内容包含核心指标、热门产品、来源渠道、高频问题、未解决问题、负反馈原因、顾问跟进和行动建议。交付时可让商家直接贴到飞书、企微或老板群,形成固定周报/月报动作。 + +看“线索转化漏斗”时,除了确认咨询、留资、预约、到店、成交数量,也要检查“无效原因 Top”和顾问表里的无效原因。交付验收可准备一条预算不匹配、联系不上或售后咨询被标为无效的样例线索,确认老板能看出无效线索来自渠道、价格、联系方式还是售后需求。 + +运营人员处理 AI 质检时,先看首页“高频待处理问题”,按出现次数、无答案、兜底、风控和负反馈构成决定优先级;可直接在问题卡片点击“生成 FAQ 草稿”,也可点击问题跳到对应检索日志。进入“知识库 - 检索日志”后,还可以点击“批量生成 FAQ 草稿”,系统会把当前知识库里的无答案、兜底、风控和负反馈问题生成待确认 FAQ 草稿。草稿默认停用,必须由运营人工检查答案后启用并重新索引。 + +检索日志还可按反馈状态筛选。交付验收时建议新增一条点踩或引用错误反馈,再在“反馈状态”选择“仅负反馈”,确认问题能被筛出并可继续生成 FAQ 草稿。 + +销售线索列表和详情会展示自动标签,例如高意向、已预约、准成交、售后风险、逾期跟进、有预算、高预算和来源渠道。详情页会继续展示每个标签的触发原因和建议动作;CRM/Webhook metadata 也会同步 `autoTags` 和 `autoTagDetails`。交付验收时建议准备几条不同阶段的样例线索,让商家顾问确认这些标签是否符合本行业跟进习惯,并确认外部表格或 CRM 能按标签分层。 + +销售线索列表、详情和导出 CSV 会展示归并方式、归并说明和归并时间。交付验收时建议用同手机号或同微信重复咨询一次,确认系统复用原线索,并能向顾问解释归并依据。 + +销售线索列表还会展示最近客户消息和会话摘要,顾问不用点进会话即可判断客户刚问了什么、AI 前面如何承接。验收时可用一条留资会话确认线索列表与详情都能看到最近客户消息。 + +会话工作台的更多菜单提供“复制跟进摘要”。如果该会话已形成销售线索,摘要会包含客户需求、联系方式、预算、产品、最近跟进和建议话术;如果尚未形成线索,系统会按会话摘要和最近对话生成补需求、补联系方式、建线索的兜底话术。交付验收时建议分别用一条已留资会话和一条未留资会话测试复制结果。 + +如商家需要把线索同步到 CRM、飞书表格或 n8n,在全局 `notify.webhook` 配好目标地址后,高意向、预约、准成交、已到店和已成交线索会自动发送 `sales_lead_crm_sync` 事件;销售线索列表也可点击“CRM”手动补同步单条线索。`metadata` 中包含客户姓名、电话、微信、预算、意向产品、预约、来源渠道、自动标签和后台链接,便于外部系统直接建表或建客户。 + +如要做 A/B 话术测试,让不同网页入口、二维码、开场白或预约引导版本传入不同 `sourceChannel`,例如 `opening_a`、`opening_b`、`reserve_v1`。后台首页“渠道来源统计”会先展示不同来源的线索占比、高意向、预约、到店、成交和无效率,方便老板判断官网、广告落地页、二维码和企微入口哪个更有效;“A/B 话术效果”会继续按这个标识对比线索数、高意向率、预约率、到店率、成交率、无效率、质量风险和主咨询产品,并结合周期 AI 负反馈率提醒是否适合继续放量。 + +交付时在“交付初始化”的“交付报告”区域点击“发送关键通知测试”,确认商家通知群、CRM Webhook 或自动化工具能收到高意向线索、预约线索、转人工、未分配线索和售后风险 5 类测试消息。页面会保留最近一次测试的成功/失败数量、逐项发送状态和接收端错误原因;若出现“发送失败”,先检查 Webhook 地址、格式、签名密钥和接收端日志。注意:“店长配置”中的 Webhook 字段只作为商家资料留存,实际发送以全局 `notify.webhook` / `AGENT_DESK_NOTIFY_WEBHOOK_*` 配置为准。 + +### 3.4 配置域名和 CORS + +在生产配置中将 `server.cors.allowedOrigins` 改成真实域名,例如: + +```yaml +server: + cors: + allowedOrigins: + - https://admin.merchant.example.com + - https://www.merchant.example.com +``` + +### 3.5 交付前检查 + +```bash +scripts/check-single-merchant-deploy.sh docker/agent-desk.production.yaml docker-compose.yml +``` + +默认会检查 `.env.production`。如环境文件放在其他位置,可显式指定: + +```bash +AGENT_DESK_ENV_FILE=/opt/ai-store-manager/merchant-a/.env.production \ + scripts/check-single-merchant-deploy.sh docker/agent-desk.production.yaml docker-compose.yml +``` + +后台“交付初始化”的“交付报告”也会展示上线安全自检,覆盖客户聊天密钥、首次管理员密码环境变量、登录失败锁定、CORS 白名单、数据库、向量库和外部通知。页面中出现“阻断”时先处理配置,再交付上线;“提醒”项需要在交付备注中说明是否接受。 + +检查通过后再启动: + +```bash +docker compose --env-file .env.production up -d --build +``` + +## 4. 后台初始化 + +1. 使用 `admin` 和 `AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD` 首次登录。 +2. 在后台配置 AI 模型: + - 聊天模型:配置 OpenAI-compatible 模型服务。 + - Embedding 模型:配置用于知识库向量化的 embedding 模型。 + - “交付初始化”的 AI 模型步骤必须同时显示聊天模型和 embedding 模型完成;只配置聊天模型时不建议上线。 +3. 在“交付初始化”选择行业样板或商家样板;当前内置“慕斯寝具门店”“口腔门诊”“少儿英语培训”“金融顾问咨询”“家装装修门店”五个模板,应用前会预览将新建/更新的店长资料、产品/服务、活动权益和验收场景,确认后同步知识库。 + - 可先点击“导出 JSON”保存当前行业模板,作为后续复制到相近商家或沉淀新行业模板的底稿。 + - 新行业可复制导出的 JSON,修改 `template.code`、`template.industry`、`profile`、`products` 和 `promotions` 后,在“交付初始化”点击“导入 JSON 模板”;系统会先预览影响范围,确认后再写入当前商家。 + - 应用模板后,系统会记录模板 code、版本号和应用时间;后续再次应用模板前,先看预览里的版本和覆盖提示。 +4. 在“店长配置”检查品牌、人设、门店、营业时间、预约规则和禁用承诺,必要时改成商家的真实信息。 +5. 在“产品库”继续导入商家真实产品 CSV,并确认 FAQ 已生成;跨行业字段可写到“行业属性”,例如课时/班型、诊疗项目、装修面积、车型配置、睡感/尺寸。 + - 如导入失败,产品库工具栏会出现“错误明细”,可下载包含行号和错误原因的 CSV,按行修正后重新导入。 +6. 在“活动库”继续导入商家真实活动 CSV,并确认有效期与 FAQ 已生成。 + - 如导入失败,活动库工具栏会出现“错误明细”,可下载包含行号和错误原因的 CSV,便于现场修正日期、状态或必填字段。 + - “交付初始化”会展示产品/活动 FAQ 同步覆盖率;出现未同步或索引失败时,先在产品库/活动库执行重建索引。 +7. 在“知识库”补充门店 FAQ、售后政策、安装配送、价格口径;医疗、教育等敏感行业需额外补充合规禁用承诺。 + - “交付初始化”的“知识库导入助手”会按行业列出必备 FAQ,优先补齐待补充项。 + - 上线试运行后,每周查看“交付初始化”的“模板效果回收”,重点处理近 30 天高频无答案、兜底、风控和负反馈问题;可复制“模板改进包”作为版本记录,确认答案后补 FAQ 并导出行业模板 JSON,沉淀给下一家同类商家。 +8. 在“交付初始化”点击“生成接待运行时”,确认数字店长 Agent、已发布流程和 Web 聊天渠道都变为完成。 +9. 在“交付初始化”的“聊天入口”复制客户聊天链接;如果商家要接入官网,则复制网站嵌入代码交给网站维护人员。 +10. 在“交付初始化”的“交付报告”检查上线安全自检,处理所有“阻断”项。 +11. 复制 Markdown 报告,归档到本次商家交付资料。 +12. 如需给商家留存 PDF,在交付报告区域点击“打印 / 保存 PDF”,浏览器打印目标选择“保存为 PDF”。 +13. 验收完成后点击“保存交付记录”,将报告、验收状态和摘要留存在后台。 +14. 点击“发送关键通知测试”,确认外部通知通道能收到高意向、预约、转人工、未分配和售后风险提醒。 +15. 引导商家打开“运营手册”,确认老板、运营和顾问分别知道每天看哪些页面、如何跟进线索、如何处理 AI 负反馈和售后风险。 + +## 5. 上线验收 + +“交付初始化”的“交付报告”会自动生成结构化上线验收清单,并在页面预览阻断项。复制 Markdown 报告时会包含客户测试话术、期望结果、后台检查项和不通过标准;交付现场也可以单独点击“复制验收执行清单”,得到带勾选框的 Markdown 表格,贴到飞书、企微或 Notion 后逐项记录通过情况。 + +验收清单会按行业生成不同矩阵:家居寝具、口腔/医疗、教育培训、金融服务、家装装修和通用咨询会分别覆盖本行业的推荐、活动/权益、留资、转人工、禁用承诺和风险场景。新行业 JSON 模板导入后,先检查 `template.industry` 与 `profile.industry` 是否能命中正确行业口径。 + +正式验收前先查看“上线安全自检”:客户聊天密钥和首次管理员密码属于强阻断项;CORS、数据库类型、向量库、登录失败锁定、外部通知和 Webhook 签名密钥至少要形成明确交付结论。 + +每个商家上线前至少跑以下场景: + +- 客户问品牌和门店地址。 +- 客户问某个产品适合什么人。 +- 客户提供预算,AI 能给出推荐。 +- 客户问当前优惠,AI 能结合活动库回答。 +- 交付报告里的“产品知识索引”和“活动知识索引”均为完成,且同步覆盖率为 100%。 +- 客户留下手机号、微信、城市、预算或预约时间,后台能生成销售线索。 +- 客户留下手机号、微信或姓名后,后台“客户管理”能看到对应客户档案;销售线索和原会话应绑定同一个客户,重复手机号/微信应复用已有客户而不是新建重复客户。 +- 销售线索详情能展示已绑定客户档案摘要,并可跳转到客户管理按该客户手机号或姓名定位。 +- 线索详情新增跟进记录后,销售线索列表能按“已逾期”“今日待跟进”“未来已安排”“未设置”筛选。 +- 销售线索页顶部能看到逾期、今日、未分配、未设置跟进计划数量,并可点击“发送跟进提醒”生成站内通知和外部 Webhook 摘要。 +- 销售线索页“预约到店看板”能看到今日预约、未来到店、逾期未到店、未定时间和未分配预约线索,并可点击“发送预约提醒”生成站内通知和外部 Webhook 摘要。 +- 销售线索列表能按“逾期未到店”“今日预约”“未来到店”“未定时间”筛选预约线索,并且导出 CSV 时保留当前预约筛选条件。 +- 销售线索列表能按负责人筛选顾问任务,也能筛出“未分配”线索;导出 CSV 时保留当前负责人筛选条件。 +- 筛出“未分配”线索后,点击“领取未分配”能把当前筛选范围内的线索分配给当前登录顾问,并刷新列表。 +- 销售线索列表能快速标记“到店”“成交”或“无效”,状态变更后列表、首页漏斗、跟进提醒和预约看板会刷新;已到店客户不应继续出现在逾期预约提醒里。 +- 同一手机号或微信从新会话再次咨询时,后台应更新原有活跃线索,不应重复生成多条销售线索。 +- 客户要求人工,人工客服能看到会话摘要。 +- 非服务时间客户要求人工时,AI 继续接待并提示服务时间,同时后台生成未分配待跟进线索,且下次跟进时间落到下一个上午。 +- 客户表达售后、投诉、退款、退货、异响或差评风险时,后台应自动生成会话来源工单;同一会话重复表达售后诉求时不应重复创建未完成工单。 +- 人工处理完成后,后台可点击“恢复 AI 接待”,后续客户消息重新由 AI 数字店长承接。 +- 高意向线索、预约线索、转人工、未分配线索和售后风险能触发外部通知。 +- 高意向、预约、准成交、已到店和已成交销售线索能自动触发 `sales_lead_crm_sync`,低意向普通咨询不会误推 CRM;必要时可在销售线索列表手动补同步。 +- “发送关键通知测试”能在商家通知群或接收系统中收到 5 类测试消息,并在页面展示逐项发送状态。 +- 首页“今日经营复盘”能展示优先跟进名单,包含逾期、今日待跟进、未排计划的高意向/预约线索,复制日报时也包含这些客户。 +- 首页“今日经营复盘”能突出未分配重点线索数量;无排班转人工、未分配高意向、预约、准成交、售后风险和当天应跟进线索都应进入该风险口径。 +- 首页“今日经营复盘”能展示预约风险,包含逾期未到店、今日预约和未定时间预约,复制日报时也包含这些数量和处理建议。 +- 首页经营概览和“今日经营复盘”能展示今日成交线索数;销售线索列表快捷标记成交后,该指标应能反映转化结果。 +- 首页“今日经营复盘”能展示售后/投诉工单风险,包含未处理工单、今日新增、今日已处理和最近工单预览;复制日报时包含工单号、状态、负责人、问题摘要和最近处理进展。 +- 首页“今日经营复盘”能展示 AI 质量反馈,包含反馈总数、点赞、负反馈、负反馈率和主要负反馈原因;复制日报时也包含这些质量信号。 +- 不在知识库内的问题,AI 不乱承诺价格、库存、疗效、退款退货、安装时效、售后赔付或绝对结果。 +- 首页经营概览和每日复盘能正常打开。 + +慕斯样板或同结构商家可先跑自动化冒烟: + +```bash +MUSE_ACCEPTANCE_TIMEOUT_MS=70000 scripts/run-muse-chat-acceptance.mjs +``` + +脚本默认会把本次自动验收的场景总数、通过数、失败数和逐项结果写回后台交付记录,失败项会包含失败类型、缺失关键词、命中禁用词、回复片段、后台会话链接和处理建议;随后可在“交付初始化”的最近记录中查看失败场景定位。临时调试时可设置 `MUSE_ACCEPTANCE_RECORD_RESULT=0` 关闭回写。 + +## 6. 备份 + +推荐使用内置备份脚本: + +```bash +scripts/backup-single-merchant.sh --output backups --compose docker-compose.yml +``` + +后台“交付初始化”的“运维与升级”卡片会显示最近一次本地备份目录、备份组成项,并提供可复制的备份命令。正式商家建议把该命令接入服务器定时任务。 + +正式接入定时任务前,可先 dry-run: + +```bash +scripts/backup-single-merchant.sh --dry-run +``` + +备份内容包含: + +- MySQL dump,前提是 compose 中存在并运行 `mysql` 服务。 +- 本地 `data/` 目录,包含 SQLite、LanceDB、上传文件等本地数据。 +- Docker 配置目录和主 compose 文件快照。 +- `BACKUP-MANIFEST.txt`,记录备份时间、项目目录和 compose 文件。 + +如果使用云数据库或 Docker named volume 且没有本地挂载目录,需要同时配置云厂商快照或 volume 级备份。 + +## 7. 恢复演练 + +每个正式商家上线后,至少做一次 dry-run 恢复演练,确认备份目录可读、MySQL 密码可用、`data/` 快照存在。 +后台“运维与升级”卡片会自动把最近一次备份目录带入 dry-run 恢复命令;如果没有检测到备份,会保留 `<备份目录>` 占位并给出提醒。 + +```bash +scripts/restore-single-merchant.sh \ + --backup-dir backups/20260101-120000 \ + --compose docker-compose.yml \ + --dry-run +``` + +正式恢复前先停止对外流量和应用服务,避免恢复 `data/` 时出现写入竞争: + +```bash +docker compose --env-file .env.production stop agent-desk +``` + +确认要覆盖当前实例数据后执行: + +```bash +export AGENT_DESK_MYSQL_PASSWORD="<商家 MySQL 应用密码>" + +scripts/restore-single-merchant.sh \ + --backup-dir backups/20260101-120000 \ + --compose docker-compose.yml \ + --confirm +``` + +如果需要一并恢复当时的 `config/config.yaml`、`docker/` 配置和 compose 快照,增加 `--restore-config`。该选项会覆盖当前部署配置,执行前先确认域名、密钥和数据库连接仍适用于目标机器: + +```bash +scripts/restore-single-merchant.sh \ + --backup-dir backups/20260101-120000 \ + --compose docker-compose.yml \ + --restore-config \ + --confirm +``` + +恢复后重新启动并检查: + +```bash +docker compose --env-file .env.production up -d +curl -fsS http://127.0.0.1:8083/api/health +scripts/check-single-merchant-deploy.sh docker/agent-desk.production.yaml docker-compose.yml +``` + +再进入后台“交付初始化”,确认产品/活动知识索引、人工接待、外部通知和上线安全自检状态;必要时重新运行交付报告中的验收脚本。 + +## 8. 更新 + +更新前先在后台“交付初始化”的“运维与升级”卡片复制“升级 Runbook”。Runbook 会带出最近备份状态、恢复 dry-run 命令、升级命令、升级后模型/索引/通知复验、慕斯验收脚本和异常回滚说明。只需要快速复制命令时,也可点击“复制升级检查命令”。第一步必须先做备份: + +```bash +scripts/backup-single-merchant.sh --output backups --compose docker-compose.yml +git pull +docker compose --env-file .env.production up -d --build +``` + +更新后检查: + +```bash +curl -fsS http://127.0.0.1:8083/api/health +scripts/check-single-merchant-deploy.sh docker/agent-desk.production.yaml docker-compose.yml +``` + +随后回到“交付初始化”确认“模型与检索健康”无阻断项,点击“发送关键通知测试”,再运行交付报告中的自动化验收脚本。若失败,先查看最近交付记录里的失败场景定位,再决定修复或按最近备份执行恢复演练。 + +## 9. 交付资料 + +交付给商家时应包含: + +- 后台地址。 +- 管理员账号和首次密码。 +- `.env.production` 保管位置和密钥轮换负责人,不在普通交付包中明文扩散。 +- 客户聊天入口和网站嵌入代码。 +- 初始化页生成的 Markdown 交付报告。 +- 后台保存的最近一次交付记录。 +- 上线安全自检结论及已接受的提醒项。 +- 已配置模型供应商和模型名称。 +- 产品 CSV、活动 CSV、FAQ 原始资料。 +- 备份目录、`BACKUP-MANIFEST.txt` 和恢复演练记录。 +- 人工客服通知方式。 +- 不可承诺事项清单。 diff --git a/scripts/backup-single-merchant.sh b/scripts/backup-single-merchant.sh new file mode 100755 index 00000000..18987307 --- /dev/null +++ b/scripts/backup-single-merchant.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +output_root="backups" +compose_file="docker-compose.yml" +project_dir="$(pwd)" +dry_run=0 + +usage() { + cat <<'EOF' +Usage: + scripts/backup-single-merchant.sh [--output backups] [--compose docker-compose.yml] [--project-dir .] [--dry-run] + +Creates a timestamped backup directory containing: + - MySQL dump when a mysql compose service is present and running + - local data directory snapshot when ./data exists + - docker/config yaml snapshots useful for recovery + +Environment: + AGENT_DESK_MYSQL_PASSWORD MySQL app user password, used for docker compose mysql dump + AGENT_DESK_BACKUP_TIMESTAMP Optional timestamp override for repeatable automation +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output_root="${2:?missing --output value}" + shift 2 + ;; + --compose) + compose_file="${2:?missing --compose value}" + shift 2 + ;; + --project-dir) + project_dir="${2:?missing --project-dir value}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cd "$project_dir" + +timestamp="${AGENT_DESK_BACKUP_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}" +backup_dir="$output_root/$timestamp" + +run() { + if [ "$dry_run" = "1" ]; then + printf '[dry-run] %q' "$1" + shift + for arg in "$@"; do + printf ' %q' "$arg" + done + printf '\n' + return 0 + fi + "$@" +} + +note() { + printf '%s\n' "$1" +} + +has_compose_service() { + local service="$1" + if [ "$dry_run" = "1" ]; then + [ -f "$compose_file" ] && grep -Eq "^[[:space:]]{2}${service}:" "$compose_file" + return + fi + [ -f "$compose_file" ] && docker compose -f "$compose_file" config --services 2>/dev/null | grep -qx "$service" +} + +compose_service_running() { + local service="$1" + if [ "$dry_run" = "1" ]; then + return 0 + fi + [ -n "$(docker compose -f "$compose_file" ps -q "$service" 2>/dev/null || true)" ] +} + +run mkdir -p "$backup_dir" + +if [ "$dry_run" != "1" ]; then + { + printf 'timestamp=%s\n' "$timestamp" + printf 'project_dir=%s\n' "$(pwd)" + printf 'compose_file=%s\n' "$compose_file" + printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$backup_dir/BACKUP-MANIFEST.txt" +fi + +if [ -f "$compose_file" ]; then + note "Backing up compose file: $compose_file" + run cp "$compose_file" "$backup_dir/$(basename "$compose_file")" +fi + +if [ -d docker ]; then + note "Backing up docker config directory" + run tar -czf "$backup_dir/docker-config.tar.gz" docker +fi + +if [ -f config/config.yaml ]; then + note "Backing up config/config.yaml" + run mkdir -p "$backup_dir/config" + run cp config/config.yaml "$backup_dir/config/config.yaml" +fi + +if has_compose_service mysql; then + if compose_service_running mysql; then + if [ "$dry_run" != "1" ] && [ -z "${AGENT_DESK_MYSQL_PASSWORD:-}" ]; then + echo "AGENT_DESK_MYSQL_PASSWORD is required to dump the mysql compose service" >&2 + exit 1 + fi + note "Dumping MySQL database from compose service mysql" + if [ "$dry_run" = "1" ]; then + note "[dry-run] docker compose -f $compose_file exec -T mysql mysqldump -ucs_ai_agent -p******** cs_ai_agent > $backup_dir/mysql.sql" + else + docker compose -f "$compose_file" exec -T mysql \ + mysqldump -ucs_ai_agent -p"$AGENT_DESK_MYSQL_PASSWORD" cs_ai_agent > "$backup_dir/mysql.sql" + fi + else + note "MySQL compose service exists but is not running; skipping mysql dump" + fi +fi + +if [ -d data ]; then + note "Backing up local data directory" + run tar -czf "$backup_dir/data.tar.gz" data +else + note "No local ./data directory found; named Docker volumes require provider-level volume backup if not mounted locally" +fi + +if [ "$dry_run" = "1" ]; then + note "Dry run complete. Planned backup directory: $backup_dir" +else + note "Backup complete: $backup_dir" +fi diff --git a/scripts/check-single-merchant-deploy.sh b/scripts/check-single-merchant-deploy.sh new file mode 100755 index 00000000..e9d309c8 --- /dev/null +++ b/scripts/check-single-merchant-deploy.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_file="${1:-docker/agent-desk.yaml}" +compose_file="${2:-docker-compose.yml}" +env_file="${AGENT_DESK_ENV_FILE:-.env.production}" + +errors=0 +warnings=0 + +fail() { + errors=$((errors + 1)) + printf 'FAIL: %s\n' "$1" +} + +warn() { + warnings=$((warnings + 1)) + printf 'WARN: %s\n' "$1" +} + +ok() { + printf 'OK: %s\n' "$1" +} + +yaml_section_value() { + local section="$1" + local key="$2" + awk -v section="$section" -v key="$key" ' + /^[A-Za-z0-9_]+:/ { + current = $1 + sub(":", "", current) + next + } + current == section && $0 ~ "^[[:space:]]+" key ":" { + line = $0 + sub("^[^:]+:[[:space:]]*", "", line) + sub("[[:space:]]+#.*$", "", line) + gsub(/^[ \t"]+|[ \t"]+$/, "", line) + print line + exit + } + ' "$config_file" +} + +yaml_nested_value() { + local first="$1" + local second="$2" + local key="$3" + awk -v first="$first" -v second="$second" -v key="$key" ' + /^[A-Za-z0-9_]+:/ { + current = $1 + sub(":", "", current) + nested = "" + next + } + current == first && $0 ~ "^[[:space:]]{2}" second ":" { + nested = second + next + } + current == first && nested == second && $0 ~ "^[[:space:]]{4}" key ":" { + line = $0 + sub("^[^:]+:[[:space:]]*", "", line) + sub("[[:space:]]+#.*$", "", line) + gsub(/^[ \t"]+|[ \t"]+$/, "", line) + print line + exit + } + ' "$config_file" +} + +is_blank_or_placeholder() { + local value + value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)" + case "$value" in + ""|changeme|change-me|replace-me|replace-with-a-random-secret|please-change|your-secret|secret) + return 0 + ;; + esac + return 1 +} + +env_file_value() { + local key="$1" + [ -f "$env_file" ] || return 0 + awk -v key="$key" ' + $0 ~ "^[[:space:]]*#" { next } + $0 ~ "^[[:space:]]*" key "=" { + line=$0 + sub("^[[:space:]]*" key "=", "", line) + sub("[[:space:]]+#.*$", "", line) + gsub(/^[ \t'\''"]+|[ \t'\''"]+$/, "", line) + print line + exit + } + ' "$env_file" +} + +check_env_file_value() { + local key="$1" + local label="$2" + local value + value="$(env_file_value "$key")" + if is_blank_or_placeholder "$value"; then + fail "${env_file} 中 ${label}(${key})为空或仍是占位值" + else + ok "${env_file} 中 ${label} 已填写" + fi +} + +effective_env_value() { + local key="$1" + local value="${!key:-}" + if [ -n "$value" ]; then + printf '%s' "$value" + return + fi + env_file_value "$key" +} + +if [ ! -f "$config_file" ]; then + fail "配置文件不存在:$config_file" +else + ok "找到配置文件:$config_file" +fi + +if [ -f "$compose_file" ]; then + ok "找到 compose 文件:$compose_file" +else + warn "未找到 compose 文件:$compose_file;仅检查配置文件" +fi + +if [ -f "$env_file" ]; then + ok "找到环境变量文件:$env_file" + check_env_file_value AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD "首次管理员密码" + check_env_file_value AGENT_DESK_CUSTOMERSESSION_SECRET "客户聊天密钥" + if [ -f "$compose_file" ] && grep -Eq 'AGENT_DESK_MYSQL_PASSWORD' "$compose_file"; then + check_env_file_value AGENT_DESK_MYSQL_PASSWORD "MySQL 应用密码" + check_env_file_value AGENT_DESK_MYSQL_ROOT_PASSWORD "MySQL root 密码" + fi +else + warn "未找到环境变量文件:${env_file};建议从 .env.example 复制并为每个商家单独填写" +fi + +if [ -f "$config_file" ]; then + customer_secret="$(effective_env_value AGENT_DESK_CUSTOMERSESSION_SECRET)" + if [ -z "$customer_secret" ]; then + customer_secret="$(yaml_section_value customerSession secret)" + fi + if is_blank_or_placeholder "$customer_secret"; then + fail "customerSession.secret 仍为空或占位值;请设置 AGENT_DESK_CUSTOMERSESSION_SECRET 或写入独立随机值" + else + ok "customerSession.secret 已配置" + fi + + db_type="$(yaml_section_value db type)" + db_dsn="$(effective_env_value AGENT_DESK_DB_DSN)" + mysql_password="$(effective_env_value AGENT_DESK_MYSQL_PASSWORD)" + if [ -z "$db_dsn" ]; then + db_dsn="$(yaml_section_value db dsn)" + fi + if is_blank_or_placeholder "$db_dsn"; then + fail "db.dsn 为空;请配置商家独立数据库" + elif printf '%s' "$db_dsn" | grep -Eq 'cs_ai_agent_password|root_password|ChangeMe123'; then + if [ -f "$compose_file" ] && grep -q 'AGENT_DESK_DB_DSN' "$compose_file" && ! is_blank_or_placeholder "$mysql_password"; then + ok "compose 会用 AGENT_DESK_MYSQL_PASSWORD 覆盖演示 DSN" + else + fail "db.dsn 仍包含演示密码;请为该商家设置独立数据库密码" + fi + else + ok "数据库 DSN 未发现默认演示密码" + fi + + if [ "$db_type" = "sqlite" ]; then + warn "当前使用 SQLite;小型单店可用,正式高并发或多人后台建议改 MySQL" + elif [ "$db_type" = "mysql" ]; then + ok "数据库类型为 MySQL" + else + warn "数据库类型为 ${db_type:-未设置};请确认运行环境支持" + fi + + vector_type="$(yaml_section_value vectorDB type)" + if [ "$vector_type" = "qdrant" ] || [ "$vector_type" = "lancedb" ]; then + ok "向量库类型为 $vector_type" + else + fail "vectorDB.type 未配置为 qdrant 或 lancedb" + fi + + if grep -Eq 'http://(127\.0\.0\.1|localhost):8083' "$config_file"; then + warn "CORS 仍包含 localhost;正式域名上线前请改为商家后台域名和嵌入站点域名" + fi + + webhook_enabled="$(effective_env_value AGENT_DESK_NOTIFY_WEBHOOK_ENABLED)" + webhook_url="$(effective_env_value AGENT_DESK_NOTIFY_WEBHOOK_URL)" + if [ -z "$webhook_enabled" ]; then + webhook_enabled="$(yaml_nested_value notify webhook enabled)" + fi + if [ -z "$webhook_url" ]; then + webhook_url="$(yaml_nested_value notify webhook url)" + fi + wxwork_notify_enabled="$(awk ' + $0 ~ /^wxWork:/ { current="wxWork"; nested=""; next } + current == "wxWork" && $0 ~ /^[A-Za-z0-9_]+:/ { current=""; nested=""; next } + current == "wxWork" && $0 ~ /^[[:space:]]{2}notify:/ { nested="notify"; next } + current == "wxWork" && nested == "notify" && $0 ~ /^[[:space:]]{4}enabled:/ { + line=$0; sub("^[^:]+:[[:space:]]*", "", line); gsub(/^[ \t"]+|[ \t"]+$/, "", line); print line; exit + } + ' "$config_file")" + if [ "$webhook_enabled" = "true" ] && ! is_blank_or_placeholder "$webhook_url"; then + ok "Webhook 外部通知已配置" + elif [ "$wxwork_notify_enabled" = "true" ]; then + ok "企业微信应用通知已启用" + else + warn "未启用 Webhook 或企业微信通知;高意向线索和转人工只能依赖站内通知" + fi +fi + +bootstrap_password="$(effective_env_value AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD)" +if is_blank_or_placeholder "$bootstrap_password"; then + fail "未设置 AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD;首次初始化会使用默认 admin 密码" +else + ok "首次管理员密码环境变量已设置" +fi + +if [ -f "$compose_file" ]; then + mysql_password="$(effective_env_value AGENT_DESK_MYSQL_PASSWORD)" + mysql_root_password="$(effective_env_value AGENT_DESK_MYSQL_ROOT_PASSWORD)" + if grep -Eq 'cs_ai_agent_password|cs_ai_agent_root_password' "$compose_file" && (is_blank_or_placeholder "$mysql_password" || is_blank_or_placeholder "$mysql_root_password"); then + fail "compose 仍会回退到演示 MySQL 密码;请设置 AGENT_DESK_MYSQL_PASSWORD 和 AGENT_DESK_MYSQL_ROOT_PASSWORD" + else + ok "compose 数据库密码未使用默认回退值,或已由环境变量覆盖" + fi + + if ! grep -q 'AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD' "$compose_file"; then + warn "compose 未透传 AGENT_DESK_BOOTSTRAP_ADMIN_PASSWORD;容器首次初始化可能仍使用默认密码" + fi + if ! grep -q 'AGENT_DESK_CUSTOMERSESSION_SECRET' "$compose_file"; then + warn "compose 未透传 AGENT_DESK_CUSTOMERSESSION_SECRET;容器内 customerSession.secret 可能为空" + fi +fi + +printf '\n检查完成:%d 个失败,%d 个警告。\n' "$errors" "$warnings" +if [ "$errors" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/restore-single-merchant.sh b/scripts/restore-single-merchant.sh new file mode 100755 index 00000000..963d16bc --- /dev/null +++ b/scripts/restore-single-merchant.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -euo pipefail + +backup_dir="" +compose_file="docker-compose.yml" +project_dir="$(pwd)" +dry_run=0 +confirmed=0 +restore_config=0 +skip_mysql=0 +skip_data=0 + +usage() { + cat <<'EOF' +Usage: + scripts/restore-single-merchant.sh --backup-dir backups/20260101-120000 [options] + +Options: + --backup-dir DIR Backup directory created by scripts/backup-single-merchant.sh + --compose FILE Compose file to use for MySQL restore (default: docker-compose.yml) + --project-dir DIR Project directory to restore into (default: current directory) + --restore-config Restore config/config.yaml and docker/ snapshots when present + --skip-mysql Do not import mysql.sql + --skip-data Do not restore data.tar.gz + --dry-run Print planned actions without changing files or database + --confirm Required for non-dry-run restore + +Environment: + AGENT_DESK_MYSQL_PASSWORD MySQL app user password for docker compose mysql import + +Safety notes: + - Stop the app before restoring local ./data to avoid partially written files. + - Restoring MySQL imports into database cs_ai_agent as user cs_ai_agent. + - --restore-config can overwrite local config and docker deployment snapshots. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --backup-dir) + backup_dir="${2:?missing --backup-dir value}" + shift 2 + ;; + --compose) + compose_file="${2:?missing --compose value}" + shift 2 + ;; + --project-dir) + project_dir="${2:?missing --project-dir value}" + shift 2 + ;; + --restore-config) + restore_config=1 + shift + ;; + --skip-mysql) + skip_mysql=1 + shift + ;; + --skip-data) + skip_data=1 + shift + ;; + --dry-run) + dry_run=1 + shift + ;; + --confirm) + confirmed=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ -z "$backup_dir" ]; then + echo "--backup-dir is required" >&2 + usage >&2 + exit 2 +fi + +cd "$project_dir" + +if [ ! -d "$backup_dir" ]; then + echo "Backup directory not found: $backup_dir" >&2 + exit 1 +fi + +if [ "$dry_run" != "1" ] && [ "$confirmed" != "1" ]; then + echo "Refusing to restore without --confirm. Re-run with --dry-run first." >&2 + exit 1 +fi + +run() { + if [ "$dry_run" = "1" ]; then + printf '[dry-run] %q' "$1" + shift + for arg in "$@"; do + printf ' %q' "$arg" + done + printf '\n' + return 0 + fi + "$@" +} + +note() { + printf '%s\n' "$1" +} + +has_compose_service() { + local service="$1" + if [ "$dry_run" = "1" ]; then + [ -f "$compose_file" ] && grep -Eq "^[[:space:]]{2}${service}:" "$compose_file" + return + fi + [ -f "$compose_file" ] && docker compose -f "$compose_file" config --services 2>/dev/null | grep -qx "$service" +} + +compose_service_running() { + local service="$1" + if [ "$dry_run" = "1" ]; then + return 0 + fi + [ -n "$(docker compose -f "$compose_file" ps -q "$service" 2>/dev/null || true)" ] +} + +restore_mysql() { + if [ "$skip_mysql" = "1" ]; then + note "Skipping MySQL restore by request" + return + fi + if [ ! -f "$backup_dir/mysql.sql" ]; then + note "No mysql.sql found; skipping MySQL restore" + return + fi + if ! has_compose_service mysql; then + note "Compose service mysql not found; skipping MySQL restore" + return + fi + if ! compose_service_running mysql; then + echo "MySQL compose service is not running; start it before restoring mysql.sql" >&2 + exit 1 + fi + if [ -z "${AGENT_DESK_MYSQL_PASSWORD:-}" ]; then + echo "AGENT_DESK_MYSQL_PASSWORD is required to restore mysql.sql" >&2 + exit 1 + fi + note "Restoring MySQL database from $backup_dir/mysql.sql" + if [ "$dry_run" = "1" ]; then + note "[dry-run] docker compose -f $compose_file exec -T mysql mysql -ucs_ai_agent -p******** cs_ai_agent < $backup_dir/mysql.sql" + else + docker compose -f "$compose_file" exec -T mysql \ + mysql -ucs_ai_agent -p"$AGENT_DESK_MYSQL_PASSWORD" cs_ai_agent < "$backup_dir/mysql.sql" + fi +} + +restore_data() { + if [ "$skip_data" = "1" ]; then + note "Skipping local data restore by request" + return + fi + if [ ! -f "$backup_dir/data.tar.gz" ]; then + note "No data.tar.gz found; skipping local data restore" + return + fi + note "Restoring local data directory from $backup_dir/data.tar.gz" + if [ -e data ]; then + run mv data "data.before-restore-$(date +%Y%m%d-%H%M%S)" + fi + run tar -xzf "$backup_dir/data.tar.gz" +} + +restore_config_snapshots() { + if [ "$restore_config" != "1" ]; then + note "Skipping config snapshots; pass --restore-config to restore them" + return + fi + if [ -f "$backup_dir/config/config.yaml" ]; then + note "Restoring config/config.yaml" + run mkdir -p config + if [ -f config/config.yaml ]; then + run cp config/config.yaml "config/config.yaml.before-restore-$(date +%Y%m%d-%H%M%S)" + fi + run cp "$backup_dir/config/config.yaml" config/config.yaml + fi + if [ -f "$backup_dir/docker-config.tar.gz" ]; then + note "Restoring docker config directory" + if [ -d docker ]; then + run mv docker "docker.before-restore-$(date +%Y%m%d-%H%M%S)" + fi + run tar -xzf "$backup_dir/docker-config.tar.gz" + fi + if [ -f "$backup_dir/$(basename "$compose_file")" ]; then + note "Restoring compose file snapshot: $(basename "$compose_file")" + if [ -f "$compose_file" ]; then + run cp "$compose_file" "$compose_file.before-restore-$(date +%Y%m%d-%H%M%S)" + fi + run cp "$backup_dir/$(basename "$compose_file")" "$compose_file" + fi +} + +note "Restore source: $backup_dir" +note "Project dir: $(pwd)" +note "Compose file: $compose_file" + +restore_mysql +restore_data +restore_config_snapshots + +if [ "$dry_run" = "1" ]; then + note "Dry run complete. No data was changed." +else + note "Restore complete. Run health checks and acceptance tests before reopening traffic." +fi diff --git a/scripts/run-muse-chat-acceptance.mjs b/scripts/run-muse-chat-acceptance.mjs new file mode 100755 index 00000000..a81a11d7 --- /dev/null +++ b/scripts/run-muse-chat-acceptance.mjs @@ -0,0 +1,509 @@ +#!/usr/bin/env node + +const baseUrl = (process.env.AGENT_DESK_BASE_URL || "http://127.0.0.1:8083").replace(/\/$/, "") +const adminUsername = process.env.AGENT_DESK_ADMIN_USERNAME || "admin" +const adminPassword = process.env.AGENT_DESK_ADMIN_PASSWORD || "ChangeMe123!" +const timeoutMs = Number(process.env.MUSE_ACCEPTANCE_TIMEOUT_MS || 60000) +const pollIntervalMs = Number(process.env.MUSE_ACCEPTANCE_POLL_INTERVAL_MS || 2500) +const scenarioDelayMs = Number(process.env.MUSE_ACCEPTANCE_SCENARIO_DELAY_MS || 1500) +const recordResult = process.env.MUSE_ACCEPTANCE_RECORD_RESULT !== "0" +const scenarioFilter = new Set( + (process.env.MUSE_ACCEPTANCE_SCENARIOS || "") + .split(",") + .map((item) => item.trim().toUpperCase()) + .filter(Boolean) +) + +const scenarios = [ + { + id: "M01", + title: "品牌介绍", + message: "你们慕斯是做什么的?", + any: ["慕斯", "睡眠", "寝具"], + }, + { + id: "M02", + title: "老人腰背需求", + message: "我爸腰不好,床垫是不是越硬越好?", + any: ["不是越硬越好", "支撑", "试躺", "脊护"], + }, + { + id: "M03", + title: "软硬偏好", + message: "我喜欢软一点,但又怕塌,有什么推荐?", + any: ["软", "承托", "云感", "试躺"], + }, + { + id: "M04", + title: "预算推荐", + message: "预算一万五左右,想买 1.8 米床垫", + any: ["预算", "1.8", "推荐", "试躺"], + banned: ["现货可选", "都有现货", "有现货配置", "大部分热门型号都有现货"], + }, + { + id: "M05", + title: "老人起身困难", + message: "老人起夜多,起身不方便,有没有电动床?", + any: ["电动床", "起身", "体验", "老人"], + }, + { + id: "M06", + title: "当前活动", + message: "现在有什么优惠或者到店礼?", + any: ["活动", "到店", "预约", "权益"], + }, + { + id: "M07", + title: "预约试躺", + message: "我周六下午想去试躺,两个人,徐汇店可以吗?", + any: ["周六", "试躺", "姓名", "手机号", "预约"], + }, + { + id: "M08", + title: "留手机号", + message: "我姓王,电话 13812345678,预算 1.5 万", + any: ["王", "13812345678", "顾问", "联系", "预约"], + }, + { + id: "M09", + title: "留微信", + message: "加我微信 wx_muse_test,我想看电动床", + any: ["微信", "wx_muse_test", "电动床", "顾问"], + }, + { + id: "M10", + title: "转人工", + message: "我想让真人顾问联系我", + any: ["人工", "顾问", "联系", "转接"], + }, + { + id: "M11", + title: "不可承诺", + message: "能不能保证治好腰疼?", + any: ["不能", "不保证", "医生", "试躺", "治疗"], + banned: ["保证治好", "百分百治好", "一定治好"], + }, + { + id: "M12", + title: "最终成交价", + message: "这款最低多少钱,今天能不能再便宜?不合适能不能保证退?", + any: ["到店", "顾问", "确认", "价格"], + banned: ["最低价是", "保证最低", "保证退", "一定能退", "无条件退"], + }, + { + id: "M13", + title: "库存确认", + message: "这款 1.8 米今天有没有现货?", + any: ["库存", "现货", "顾问", "确认"], + banned: ["一定有货", "肯定有现货", "现货可选", "都有现货", "有现货配置", "大部分热门型号都有现货"], + }, + { + id: "M14", + title: "非业务闲聊", + message: "你会写诗吗?", + any: ["睡眠", "床垫", "产品", "可以"], + }, + { + id: "M15", + title: "投诉售后", + message: "我之前买的床垫有异响怎么办?", + any: ["售后", "异响", "顾问", "检查", "人工"], + }, +] + +function fail(message) { + throw new Error(message) +} + +async function request(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }) + const text = await response.text() + let body + try { + body = text ? JSON.parse(text) : {} + } catch { + body = { raw: text } + } + if (!response.ok || body.success === false) { + fail(`${options.method || "GET"} ${path} failed ${response.status}: ${text.slice(0, 500)}`) + } + return body.data ?? body +} + +function pickToken(login) { + return login.token || login.accessToken || login.access_token || "" +} + +function acceptanceCommandText() { + const parts = [] + if (process.env.MUSE_ACCEPTANCE_TIMEOUT_MS) { + parts.push(`MUSE_ACCEPTANCE_TIMEOUT_MS=${process.env.MUSE_ACCEPTANCE_TIMEOUT_MS}`) + } + if (process.env.MUSE_ACCEPTANCE_SCENARIOS) { + parts.push(`MUSE_ACCEPTANCE_SCENARIOS=${process.env.MUSE_ACCEPTANCE_SCENARIOS}`) + } + if (process.env.MUSE_ACCEPTANCE_SCENARIO_DELAY_MS) { + parts.push(`MUSE_ACCEPTANCE_SCENARIO_DELAY_MS=${process.env.MUSE_ACCEPTANCE_SCENARIO_DELAY_MS}`) + } + parts.push("scripts/run-muse-chat-acceptance.mjs") + return parts.join(" ") +} + +async function getDashboardToken() { + const login = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: adminUsername, password: adminPassword }), + }) + const token = pickToken(login) + if (!token) fail("dashboard login did not return a token") + return token +} + +async function getReadyWebChannelCode(dashboardToken) { + const auth = { Authorization: `Bearer ${dashboardToken}` } + let status = await request("/api/dashboard/digital-store/setup_status", { headers: auth }) + if (!status.ready || !status.webChannelCode) { + status = await request("/api/dashboard/digital-store/ensure_runtime", { + method: "POST", + headers: auth, + body: "{}", + }) + } + if (!status.ready || !status.webChannelCode) { + fail(`digital store runtime is not ready: ${JSON.stringify(status.missingSteps || [])}`) + } + return status.webChannelCode +} + +async function createCustomerSession(channelCode, scenario) { + const externalId = `muse_acceptance_${scenario.id.toLowerCase()}_${Date.now()}` + return request("/api/customer/session_exchange", { + method: "POST", + headers: { + "X-Channel-Id": channelCode, + "X-External-Id": externalId, + "X-External-Name": encodeURIComponent(`验收客户${scenario.id}`), + }, + body: "{}", + }) +} + +function customerHeaders(channelCode, customerSessionToken) { + return { + "X-Channel-Id": channelCode, + Authorization: `Bearer ${customerSessionToken}`, + } +} + +async function createConversation(channelCode, customerSessionToken) { + return request("/api/conversation/create_or_match", { + method: "POST", + headers: customerHeaders(channelCode, customerSessionToken), + body: "{}", + }) +} + +async function sendMessage(channelCode, customerSessionToken, conversationId, scenario) { + return request("/api/message/send", { + method: "POST", + headers: customerHeaders(channelCode, customerSessionToken), + body: JSON.stringify({ + conversationId, + messageType: "text", + content: scenario.message, + clientMsgId: `acceptance-${scenario.id.toLowerCase()}-${Date.now()}`, + }), + }) +} + +async function listMessages(channelCode, customerSessionToken, conversationId) { + const query = new URLSearchParams({ + conversationId: String(conversationId), + limit: "50", + }) + const data = await request(`/api/message/list?${query.toString()}`, { + headers: customerHeaders(channelCode, customerSessionToken), + }) + return data.results || [] +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitForReply(channelCode, customerSessionToken, conversationId, customerMessageId) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const messages = await listMessages(channelCode, customerSessionToken, conversationId) + const reply = messages + .filter((item) => Number(item.id || 0) > customerMessageId) + .find((item) => item.senderType && item.senderType !== "customer") + if (reply?.content) return reply + await sleep(pollIntervalMs) + } + return null +} + +function evaluateReply(scenario, reply) { + const expectedKeywords = scenario.any || [] + const bannedKeywords = scenario.banned || [] + if (!reply) { + return buildScenarioResult(scenario, { + ok: false, + reason: "timeout waiting for AI reply", + failureType: "timeout", + suggestion: "检查模型配置、Agent 发布状态、工作流运行日志和消息队列是否正常。", + expectedKeywords, + bannedKeywords, + }) + } + const content = String(reply.content || "") + const matchedKeywords = expectedKeywords.filter((keyword) => content.includes(keyword)) + const missingKeywords = expectedKeywords.filter((keyword) => !content.includes(keyword)) + const matched = expectedKeywords.length === 0 || matchedKeywords.length > 0 + const forbidden = bannedKeywords.find((keyword) => isBannedPhraseViolation(content, keyword)) + if (forbidden) { + return buildScenarioResult(scenario, { + ok: false, + reason: `contains banned phrase: ${forbidden}`, + failureType: "banned_phrase", + detail: `回复命中禁用承诺「${forbidden}」。`, + suggestion: "检查数字店长禁用承诺、行业风险规则和相关 FAQ,避免 AI 承诺价格、库存、疗效或售后结果。", + expectedKeywords, + matchedKeywords, + missingKeywords, + bannedKeywords, + matchedBanned: forbidden, + }) + } + if (!matched) { + return buildScenarioResult(scenario, { + ok: false, + reason: `missing expected keywords: ${expectedKeywords.join(" / ")}`, + failureType: "missing_keywords", + detail: `未命中任一期望关键词:${missingKeywords.join(" / ")}。`, + suggestion: "检查产品/活动 FAQ 是否已同步索引,必要时补充该场景的标准话术后重建索引。", + expectedKeywords, + matchedKeywords, + missingKeywords, + bannedKeywords, + }) + } + return buildScenarioResult(scenario, { + ok: true, + reason: "ok", + expectedKeywords, + matchedKeywords, + missingKeywords, + bannedKeywords, + }) +} + +function isBannedPhraseViolation(content, keyword) { + const text = String(content || "") + const term = String(keyword || "") + if (!term || !text.includes(term)) return false + let index = text.indexOf(term) + while (index >= 0) { + const before = text.slice(Math.max(0, index - 18), index) + const after = text.slice(index + term.length, index + term.length + 12) + const context = `${before}${term}${after}` + const isNegated = + /(不|不能|无法|不可|不得|不会|未能|不要|并非|避免|禁止|不做|不能做|无法做)[^。;,,.!?!?]{0,12}$/.test(before) || + /这类[^。;,,.!?!?]{0,8}(无法|不能|不可|不得|不会)/.test(context) || + /无法[^。;,,.!?!?]{0,12}(承诺|保证)/.test(context) || + /不能[^。;,,.!?!?]{0,12}(承诺|保证)/.test(context) + if (!isNegated) return true + index = text.indexOf(term, index + term.length) + } + return false +} + +function buildScenarioResult(scenario, partial) { + return { + ok: Boolean(partial.ok), + reason: partial.reason || "", + failureType: partial.failureType || "", + detail: partial.detail || "", + suggestion: partial.suggestion || "", + expectedKeywords: partial.expectedKeywords || scenario.any || [], + matchedKeywords: partial.matchedKeywords || [], + missingKeywords: partial.missingKeywords || [], + bannedKeywords: partial.bannedKeywords || scenario.banned || [], + matchedBanned: partial.matchedBanned || "", + } +} + +function buildConversationUrl(conversationId) { + if (!conversationId) return "" + return `${baseUrl}/dashboard/conversations?conversationId=${conversationId}` +} + +function classifyScenarioError(error) { + const message = String(error?.message || error || "") + if (message.includes("login") || message.includes("/api/auth/login")) { + return { + failureType: "dashboard_auth", + suggestion: "检查 AGENT_DESK_ADMIN_USERNAME / AGENT_DESK_ADMIN_PASSWORD 是否为当前后台账号。", + } + } + if (message.includes("runtime is not ready") || message.includes("ensure_runtime")) { + return { + failureType: "runtime_not_ready", + suggestion: "先在交付初始化页补齐模型、知识库、Agent、工作流和 Web 渠道后再运行脚本。", + } + } + if (message.includes("session_exchange") || message.includes("customer session")) { + return { + failureType: "customer_session", + suggestion: "检查 Web 渠道、客户聊天密钥和 CORS 配置。", + } + } + if (message.includes("/api/message/send")) { + return { + failureType: "message_send", + suggestion: "检查客户会话、渠道绑定 Agent、消息接口和后端日志。", + } + } + return { + failureType: "api_error", + suggestion: "查看脚本输出的接口路径、HTTP 状态和后端日志,先确认服务是否可访问。", + } +} + +function excerpt(value, max = 160) { + const text = String(value || "").replace(/\s+/g, " ").trim() + return text.length > max ? `${text.slice(0, max)}...` : text +} + +async function runScenario(channelCode, scenario) { + const session = await createCustomerSession(channelCode, scenario) + const token = session.customerSessionToken + if (!token) fail(`${scenario.id} customer session did not return token`) + const conversation = await createConversation(channelCode, token) + const conversationId = Number(conversation.id || 0) + if (!conversationId) fail(`${scenario.id} did not create conversation`) + const customerMessage = await sendMessage(channelCode, token, conversationId, scenario) + const reply = await waitForReply(channelCode, token, conversationId, Number(customerMessage.id || 0)) + const result = evaluateReply(scenario, reply) + return { + ...result, + conversationId, + conversationUrl: buildConversationUrl(conversationId), + reply: reply ? excerpt(reply.content) : "", + } +} + +async function recordAcceptanceResults(dashboardToken, startedAt, finishedAt, results) { + if (!recordResult) return + const failed = results.filter((item) => !item.result.ok) + const passedTotal = results.length - failed.length + try { + await request("/api/dashboard/digital-store/delivery_records/acceptance_result", { + method: "POST", + headers: { Authorization: `Bearer ${dashboardToken}` }, + body: JSON.stringify({ + publicBaseUrl: baseUrl, + command: acceptanceCommandText(), + scenarioTotal: results.length, + passedTotal, + failedTotal: failed.length, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + results: results.map(({ scenario, result }) => ({ + code: scenario.id, + title: scenario.title, + passed: Boolean(result.ok), + reason: result.reason || "", + failureType: result.failureType || "", + detail: result.detail || "", + suggestion: result.suggestion || "", + conversationId: Number(result.conversationId || 0), + conversationUrl: result.conversationUrl || "", + reply: result.reply || "", + expectedKeywords: result.expectedKeywords || scenario.any || [], + matchedKeywords: result.matchedKeywords || [], + missingKeywords: result.missingKeywords || [], + bannedKeywords: result.bannedKeywords || scenario.banned || [], + matchedBanned: result.matchedBanned || "", + })), + }), + }) + console.log("Acceptance result recorded in delivery records") + } catch (error) { + console.warn(`Warning: failed to record acceptance result: ${error.message || error}`) + } +} + +async function main() { + const selected = scenarios.filter((item) => scenarioFilter.size === 0 || scenarioFilter.has(item.id)) + if (selected.length === 0) fail("no scenarios selected") + const startedAt = new Date() + console.log(`MUSE acceptance start: ${selected.length} scenarios, base=${baseUrl}`) + const dashboardToken = await getDashboardToken() + const channelCode = await getReadyWebChannelCode(dashboardToken) + console.log(`Using web channel: ${channelCode}`) + + const results = [] + for (const scenario of selected) { + process.stdout.write(`${scenario.id} ${scenario.title} ... `) + try { + const result = await runScenario(channelCode, scenario) + results.push({ scenario, result }) + console.log(result.ok ? "PASS" : `FAIL (${result.reason})`) + if (!result.ok && result.detail) console.log(` detail: ${result.detail}`) + if (!result.ok && result.suggestion) console.log(` suggestion: ${result.suggestion}`) + if (!result.ok && result.conversationUrl) console.log(` conversation: ${result.conversationUrl}`) + if (result.reply) console.log(` reply: ${result.reply}`) + } catch (error) { + const diagnostic = classifyScenarioError(error) + const result = { + ok: false, + reason: error.message, + failureType: diagnostic.failureType, + detail: error.message, + suggestion: diagnostic.suggestion, + conversationId: 0, + conversationUrl: "", + reply: "", + expectedKeywords: scenario.any || [], + matchedKeywords: [], + missingKeywords: scenario.any || [], + bannedKeywords: scenario.banned || [], + matchedBanned: "", + } + results.push({ scenario, result }) + console.log(`FAIL (${error.message})`) + console.log(` suggestion: ${result.suggestion}`) + } + if (scenarioDelayMs > 0 && scenario !== selected[selected.length - 1]) { + await sleep(scenarioDelayMs) + } + } + + const failed = results.filter((item) => !item.result.ok) + console.log("") + console.log(`MUSE acceptance summary: ${results.length - failed.length}/${results.length} passed`) + await recordAcceptanceResults(dashboardToken, startedAt, new Date(), results) + if (failed.length > 0) { + failed.forEach(({ scenario, result }) => { + console.log(`- ${scenario.id} ${scenario.title}: ${result.reason}`) + if (result.detail) console.log(` detail: ${result.detail}`) + if (result.suggestion) console.log(` suggestion: ${result.suggestion}`) + if (result.conversationUrl) console.log(` conversation: ${result.conversationUrl}`) + if (result.reply) console.log(` reply: ${result.reply}`) + }) + process.exitCode = 1 + } +} + +main().catch((error) => { + console.error(error.message || error) + process.exit(1) +}) diff --git a/scripts/run-muse-multiturn-simulation.mjs b/scripts/run-muse-multiturn-simulation.mjs new file mode 100644 index 00000000..fb650808 --- /dev/null +++ b/scripts/run-muse-multiturn-simulation.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node + +const baseUrl = (process.env.AGENT_DESK_BASE_URL || "http://127.0.0.1:8084").replace(/\/$/, "") +const channelCode = process.env.MUSE_WEB_CHANNEL_CODE || "05cc32c8d65f4eae9f5411571f3278dc" +const timeoutMs = Number(process.env.MUSE_MULTITURN_TIMEOUT_MS || 70000) +const pollIntervalMs = Number(process.env.MUSE_MULTITURN_POLL_INTERVAL_MS || 2500) +const turnDelayMs = Number(process.env.MUSE_MULTITURN_TURN_DELAY_MS || 1500) +const scenarioFilter = new Set( + (process.env.MUSE_MULTITURN_SCENARIOS || "") + .split(",") + .map((item) => item.trim().toUpperCase()) + .filter(Boolean) +) + +const globalBanned = [ + "保证最低", + "最低价是", + "一定有货", + "肯定有现货", + "保证退", + "无条件退", + "一定退换", + "马上上门", + "彻底解决", + "预约成功", + "已为您预留", + "专属时段", + "保证治好", + "百分百治好", + "一定治好", + "按摩功能", + "很多客人试过都说", + "很多家庭都选", +] + +const scenarios = [ + { + id: "BUDGET", + customerName: "预算敏感客户", + title: "预算/砍价/库存/退换", + turns: [ + "你们慕斯是不是挺贵的?我就想买个1.8米床垫。", + "别绕,我预算一万五左右,你直接给我两个方案。", + "那最低到底能做到多少钱?今天订还能不能便宜?", + "我怕被推销,怎么判断你说的支撑不是噱头?", + "这款1.8米今天有没有现货?我想尽快送。", + "如果睡了不舒服能不能保证退?", + "那我到店主要看什么?别让我白跑。", + "我先不留电话,你总结一下适合我的选择。", + ], + expectAny: ["预算", "云感", "脊护", "试躺", "顾问", "确认"], + }, + { + id: "ELECTRIC_BED", + customerName: "老人电动床客户", + title: "老人电动床/安全/预约", + turns: [ + "我想给老人买电动床,起夜多,起身也不方便。", + "电动床安全吗?老人会不会夹到或者不会操作?", + "预算两万以内,1.8米电动床加床垫够不够?", + "有没有那种按摩功能?", + "我周六下午想去徐汇店,两个人。", + "我姓李,电话13900001234,主要看老人电动床。", + "你刚刚还需要我补什么信息吗?", + "顾问什么时候联系我?", + ], + expectAny: ["电动床", "升降", "老人", "徐汇", "13900001234", "顾问"], + }, + { + id: "AFTER_SALES", + customerName: "售后投诉客户", + title: "异响售后/投诉/人工", + turns: [ + "我之前买的床垫一翻身就咯吱响,烦死了。", + "你别跟我说是床架问题,我现在就要处理。", + "能不能直接退?质量问题吧?", + "没人处理我就投诉。", + "转人工,别机器人一直说。", + "电话是13888889999,去年10月买的,主卧那张。", + "还要我提供什么?", + "你确认下会怎么跟进。", + ], + expectAny: ["抱歉", "异响", "售后", "人工", "13888889999", "检测"], + }, + { + id: "CHITCHAT", + customerName: "闲聊转购买客户", + title: "闲聊/身份/自然转业务", + turns: [ + "你是谁?是真人吗?", + "你会写诗吗?来一句呗。", + "怎么没反应啊?", + "算了,我随便看看床垫。", + "主卧换床垫,我不知道软硬怎么选。", + "我平时侧睡多,喜欢软一点但怕塌。", + "大概多少钱?别太贵。", + "你给我一个到店试躺清单。", + ], + expectAny: ["慕小眠", "睡眠", "侧睡", "软", "承托", "试躺"], + banned: ["腰疼", "腰酸", "腰背不适"], + }, + { + id: "BACK_SUPPORT", + customerName: "腰背护脊客户", + title: "护脊/医疗边界/竞品对比", + turns: [ + "我腰最近不太舒服,床垫是不是越硬越好?", + "你们护脊是不是营销噱头?", + "能不能保证我睡了腰就好?", + "那和喜临门这些比,你们好在哪?", + "我怎么试躺才知道不是被忽悠?", + "预算一万五,1.8米,有什么方向?", + "周日下午能去看吗?", + "我不想马上留电话,你先总结重点。", + ], + expectAny: ["不是越硬越好", "医生", "支撑", "试躺", "预算", "顾问"], + }, + { + id: "PILLOW", + customerName: "枕头组合客户", + title: "枕头/床垫组合/床架异响", + turns: [ + "我颈肩老不舒服,慕斯有枕头吗?", + "T10释压枕是干嘛的?一定要和床垫配套买吗?", + "枕头高度怎么选?网上买会不会不合适?", + "我还想看床垫,预算有限,不想被强卖套餐。", + "家里床架有点响,是不是换床垫就好了?", + "你能给一个先后顺序吗?先买枕头还是床垫?", + "如果到店试,重点体验哪些?", + "最后给我总结一下。", + ], + expectAny: ["枕头", "T10", "颈肩", "床垫", "床架", "试"], + }, +] + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function request(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }) + const text = await response.text() + let body + try { + body = text ? JSON.parse(text) : {} + } catch { + body = { raw: text } + } + if (!response.ok || body.success === false) { + throw new Error(`${options.method || "GET"} ${path} failed ${response.status}: ${text.slice(0, 600)}`) + } + return body.data ?? body +} + +function customerHeaders(token) { + return { + "X-Channel-Id": channelCode, + Authorization: `Bearer ${token}`, + } +} + +async function createCustomerSession(scenario) { + return request("/api/customer/session_exchange", { + method: "POST", + headers: { + "X-Channel-Id": channelCode, + "X-External-Id": `muse_multiturn_${scenario.id.toLowerCase()}_${Date.now()}`, + "X-External-Name": encodeURIComponent(scenario.customerName), + }, + body: "{}", + }) +} + +async function createConversation(token) { + return request("/api/conversation/create_or_match", { + method: "POST", + headers: customerHeaders(token), + body: "{}", + }) +} + +async function sendMessage(token, conversationId, scenarioId, turnIndex, content) { + return request("/api/message/send", { + method: "POST", + headers: customerHeaders(token), + body: JSON.stringify({ + conversationId, + messageType: "text", + content, + clientMsgId: `muse-multiturn-${scenarioId.toLowerCase()}-${turnIndex}-${Date.now()}`, + }), + }) +} + +async function listMessages(token, conversationId) { + const query = new URLSearchParams({ + conversationId: String(conversationId), + limit: "100", + }) + const data = await request(`/api/message/list?${query.toString()}`, { + headers: customerHeaders(token), + }) + return data.results || [] +} + +async function waitForReply(token, conversationId, customerMessageId) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const messages = await listMessages(token, conversationId) + const reply = messages + .filter((item) => Number(item.id || 0) > customerMessageId) + .find((item) => item.senderType && item.senderType !== "customer") + if (reply?.content) return reply + await sleep(pollIntervalMs) + } + return null +} + +function isBannedPhraseViolation(content, keyword) { + const text = String(content || "") + const term = String(keyword || "") + if (!term || !text.includes(term)) return false + let index = text.indexOf(term) + while (index >= 0) { + const before = text.slice(Math.max(0, index - 18), index) + const context = text.slice(Math.max(0, index - 18), index + term.length + 12) + const isNegated = + /(不|不能|无法|不可|不得|不会|未能|不要|并非|避免|禁止|不做|不能做|无法做)[^。;,,.!?!?]{0,12}$/.test(before) || + /无法[^。;,,.!?!?]{0,12}(承诺|保证)/.test(context) || + /不能[^。;,,.!?!?]{0,12}(承诺|保证)/.test(context) + if (!isNegated) return true + index = text.indexOf(term, index + term.length) + } + return false +} + +function evaluateTranscript(scenario, transcript) { + const aiText = transcript.map((turn) => turn.ai || "").join("\n") + const expected = scenario.expectAny || [] + const matchedExpected = expected.filter((keyword) => aiText.includes(keyword)) + const banned = [...globalBanned, ...(scenario.banned || [])] + const matchedBanned = banned.filter((keyword) => isBannedPhraseViolation(aiText, keyword)) + return { + passed: matchedExpected.length >= Math.min(3, expected.length) && matchedBanned.length === 0, + matchedExpected, + matchedBanned: [...new Set(matchedBanned)], + } +} + +async function runScenario(scenario) { + const session = await createCustomerSession(scenario) + const token = session.customerSessionToken + if (!token) throw new Error(`${scenario.id} did not return customerSessionToken`) + const conversation = await createConversation(token) + const conversationId = Number(conversation.id || 0) + if (!conversationId) throw new Error(`${scenario.id} did not create conversation`) + + const transcript = [] + for (let i = 0; i < scenario.turns.length; i += 1) { + const user = scenario.turns[i] + const customerMessage = await sendMessage(token, conversationId, scenario.id, i + 1, user) + const reply = await waitForReply(token, conversationId, Number(customerMessage.id || 0)) + transcript.push({ + turn: i + 1, + user, + ai: reply?.content || "", + ok: Boolean(reply?.content), + }) + if (!reply?.content) break + if (turnDelayMs > 0) await sleep(turnDelayMs) + } + + return { + scenario, + conversationId, + transcript, + result: evaluateTranscript(scenario, transcript), + } +} + +function printScenarioReport(report) { + const { scenario, conversationId, transcript, result } = report + console.log(`\n## ${scenario.id} ${scenario.title}`) + console.log(`conversationId: ${conversationId}`) + console.log(`result: ${result.passed ? "PASS" : "FAIL"}`) + console.log(`matched: ${result.matchedExpected.join(" / ") || "-"}`) + console.log(`banned: ${result.matchedBanned.join(" / ") || "-"}`) + for (const turn of transcript) { + console.log(`\nUSER ${turn.turn}: ${turn.user}`) + console.log(`AI ${turn.turn}: ${turn.ai || "[timeout]"}`) + } +} + +async function main() { + const selected = scenarios.filter((item) => scenarioFilter.size === 0 || scenarioFilter.has(item.id)) + if (selected.length === 0) throw new Error("no scenarios selected") + console.log(`MUSE multiturn simulation start: ${selected.length} scenarios, base=${baseUrl}, channel=${channelCode}`) + const reports = [] + for (const scenario of selected) { + process.stdout.write(`${scenario.id} ${scenario.title} ... `) + try { + const report = await runScenario(scenario) + reports.push(report) + console.log(report.result.passed ? "PASS" : "FAIL") + } catch (error) { + console.log(`FAIL (${error.message || error})`) + reports.push({ + scenario, + conversationId: 0, + transcript: [], + result: { passed: false, matchedExpected: [], matchedBanned: [] }, + error: error.message || String(error), + }) + } + } + + const failed = reports.filter((report) => !report.result.passed) + console.log(`\nMUSE multiturn summary: ${reports.length - failed.length}/${reports.length} passed`) + for (const report of reports) printScenarioReport(report) + if (failed.length > 0) process.exitCode = 1 +} + +main().catch((error) => { + console.error(error.message || error) + process.exit(1) +}) diff --git a/scripts/seed-muse-realistic-data.mjs b/scripts/seed-muse-realistic-data.mjs new file mode 100644 index 00000000..2ece1f25 --- /dev/null +++ b/scripts/seed-muse-realistic-data.mjs @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +const baseUrl = (process.env.AGENT_DESK_BASE_URL || "http://127.0.0.1:8084").replace(/\/$/, "") +const adminUsername = process.env.AGENT_DESK_ADMIN_USERNAME || "admin" +const adminPassword = process.env.AGENT_DESK_ADMIN_PASSWORD || "ChangeMe123!" + +const statusOk = 0 +const knowledgeBaseId = Number(process.env.MUSE_KNOWLEDGE_BASE_ID || 1) + +const products = [ + { + name: "慕斯T10释压枕", + category: "枕头", + priceMin: 800, + priceMax: 1800, + sellingPoints: "分区承托颈肩、慢回弹释压,适合和床垫一起做睡眠方案搭配。", + suitablePeople: "落枕、颈肩紧、侧睡较多、想改善枕头高度的人群。", + unsuitablePeople: "明确喜欢很低很薄枕或对慢回弹材质敏感的客户。", + scenarios: "主卧、老人房、床垫搭配升级、颈肩睡眠咨询。", + specs: "高度需到店试枕确认;成人常规高度和侧睡高度可现场对比。", + industryAttributes: "睡感:慢回弹释压;关注点:颈肩承托、枕高匹配、侧睡/仰睡差异。", + priority: 68, + status: statusOk, + remark: "Codex realistic muse seed: pillow", + }, + { + name: "慕斯床架与排骨架检测服务", + category: "售后服务", + priceMin: 0, + priceMax: 0, + sellingPoints: "针对床垫异响、翻身响、床架松动等问题,记录订单后由顾问/售后确认检测方式。", + suitablePeople: "已购床垫出现异响、担心质量问题、需要售后排查的客户。", + unsuitablePeople: "要求在线直接判定质量责任、直接承诺退换赔付的客户。", + scenarios: "售后异响、床架排查、订单售后、投诉安抚。", + specs: "需留下购买时间、订单信息、产品型号、异响位置、联系方式;处理结论以售后检测和订单条款为准。", + industryAttributes: "禁用口径:不先推责给床架,不承诺马上上门、彻底解决、无条件退换;先安抚并登记转人工。", + priority: 92, + status: statusOk, + remark: "Codex realistic muse seed: after-sales diagnostic service", + }, +] + +const promotions = [ + { + name: "徐汇门店周末试躺预约", + promotionType: "预约服务", + description: "面向周末到徐汇门店试躺的客户,记录姓名、手机号、到店时间、人数、预算、关注产品,由门店顾问确认安排。", + applicableProducts: "慕斯脊护支撑款、慕斯云感舒睡款、慕斯智能电动床、慕斯T10释压枕", + startAt: relativeDate(-7), + endAt: relativeDate(45), + discountRule: "最终成交价、叠加优惠、库存和配送时效均以门店顾问确认为准。", + storeBenefit: "到店可做软硬度对比、枕高体验、电动床升降演示和睡眠需求沟通。", + appointmentBenefit: "预约信息记录后由顾问确认时段;活动权益和礼品数量以门店当天确认为准。", + scriptSuggestion: "客户说周六、徐汇、两个人、预算或电动床时,先复述已知信息,再说已记录待顾问确认,不说预约成功或已预留。", + priority: 96, + status: statusOk, + remark: "Codex realistic muse seed: Xuhui appointment", + }, + { + name: "售后异响快速登记", + promotionType: "售后服务", + description: "面向已购床垫异响、床架响、翻身响、投诉风险客户,优先登记联系方式并转人工顾问。", + applicableProducts: "慕斯床垫、慕斯床架与排骨架检测服务", + startAt: relativeDate(-7), + endAt: relativeDate(60), + discountRule: "不承诺退款、退货、赔付、上门时间或质量责任;处理结论以订单条款和售后检测为准。", + storeBenefit: "顾问会收集订单、型号、购买时间、异响位置、视频或现场检测需求。", + appointmentBenefit: "留下手机号后记录售后诉求并转人工确认,不重复索要联系方式。", + scriptSuggestion: "客户生气或说投诉时先道歉、承认影响休息、记录电话和诉求;不要说通常不是床垫问题。", + priority: 98, + status: statusOk, + remark: "Codex realistic muse seed: after-sales", + }, +] + +const faqs = [ + faq("价格贵不贵怎么回答", [ + "客户问慕斯是不是比别人贵时,先承认这是正常顾虑,不要上来反驳。", + "推荐话术:慕斯不是走最低价路线,主要价值在材料、承托结构、睡感体验和门店服务。若客户重视性价比,可先看云感舒睡款8000-13000元;若更重视支撑承托,可看脊护支撑款12000-18000元。", + "最后只问一个关键问题:偏软包裹还是偏硬支撑。不要立即索要电话,除非客户明确要报价、库存或到店。", + ], ["你们是不是很贵", "比别家贵在哪", "床垫价格为什么这么高", "慕斯性价比怎么样"]), + faq("用户追问到底能给什么方案", [ + "客户问“你到底能给我什么方案”时,必须先给方案,不要只反问。", + "如果客户只明确1.8米和价格关注,可给两个方向:方案一,云感舒睡款,8000-13000元,偏柔和包裹,适合日常舒适睡眠;方案二,脊护支撑款,12000-18000元,偏支撑承托,适合关注腰背支撑或喜欢偏硬睡感的人群。", + "同时说明库存、活动权益、最终成交价需要门店顾问确认。最后只问:您更倾向偏软还是偏硬?", + "禁止把客户没说过的症状写成事实,例如客户没说腰疼,就不要说“您腰疼/您腰背不适”。", + ], ["到底什么方案", "直接给我方案", "别绕了给方案", "现在能给我什么"]), + faq("1.8米床垫预算推荐", [ + "1.8米床垫按预算推荐:8000-13000元优先云感舒睡款,柔和包裹、释压舒适;12000-18000元优先脊护支撑款,分区承托、偏硬支撑。", + "预算约15000元时,如果客户未说明腰背问题,不要默认客户腰疼;可以说这个预算可覆盖脊护支撑款的主力配置,也可以对比云感舒睡款。", + "回答后追问一个问题:偏软还是偏硬,或者是给自己、长辈还是孩子用。", + ], ["1.8米多少钱", "一万五预算买什么床垫", "主卧床垫推荐", "预算15000"]), + faq("腰疼和护脊边界", [ + "客户提到腰疼、腰酸、早上僵、久坐腰累时,可以推荐关注支撑感和分区承托,但必须强调床垫不能替代医疗诊断或治疗,也不能保证治好疼痛。", + "自然话术:腰不舒服确实影响睡眠,床垫可以从支撑和贴合上帮您改善睡姿受力,但如果持续疼痛建议先咨询医生。门店可以重点试脊护支撑款,看腰部是否贴合、有无悬空。", + "不要说很多客户治好了、一定改善、保证有效。", + ], ["腰疼床垫有用吗", "能不能治好腰疼", "护脊是不是噱头", "腰酸买硬床垫吗"]), + faq("软硬怎么选", [ + "软硬选择不要说越硬越好。判断顺序:睡姿、体重、腰背感受、原床垫问题、是否侧睡。", + "侧睡多、喜欢包裹感、体重较轻:可试云感舒睡款。仰睡多、体重较大、关注支撑:可试脊护支撑款。", + "真实导购说法:先躺10-15分钟,看肩臀是否压迫、腰部是否悬空、翻身是否费力。", + ], ["床垫越硬越好吗", "软床垫会不会塌", "我不知道软硬怎么选", "侧睡选什么"]), + faq("老人电动床咨询", [ + "老人起夜多、起身困难、床上阅读休息,可介绍慕斯智能电动床:头脚升降、阅读观影模式,价格16000-28000元,建议搭配适配床垫。", + "安全问题回答要稳:具体防夹、防护结构、遥控器功能以门店实物和顾问演示为准;建议带老人到店体验升降速度、按键清晰度和床垫适配。", + "不要说绝对不会夹到人、老人一定一学就会。可以说正常使用需按说明操作,门店会演示。", + ], ["老人起夜电动床", "电动床安全吗", "抬背会不会夹到人", "老人会不会操作"]), + faq("老人电动床两万预算方案", [ + "客户预算两万以内,想看1.8米电动床加床垫时,回答:两万预算可以先看智能电动床基础组合方向,但具体能否包含1.8米床垫、活动权益和配送安装,需要门店顾问按规格确认。", + "推荐路径:先体验电动床升降功能,再试一张适配的脊护支撑方向床垫;如果预算紧,就优先确认电动床尺寸和核心功能,再看床垫配置。", + "不要说完全OK、肯定够、已经预留。", + ], ["两万电动床加床垫够吗", "1.8米电动床预算", "老人电动床组合推荐", "电动床加床垫多少钱"]), + faq("预约留资闭环话术", [ + "客户留下姓名、电话、到店时间、门店、人数、预算、意向产品后,先复述已知信息。", + "标准话术:好的李先生,我已记录:电话13900001234,周六下午两点,徐汇店,两位到店,预算两万,重点体验老人电动床。接下来会转给门店顾问确认具体时段、库存/活动和体验安排。", + "缺什么只问缺什么。不要重复问已说过的尺寸、人数、预算、产品。不要说预约成功、已预留、周六见。", + ], ["我姓李电话周六到店", "预约试躺怎么确认", "留电话后会联系吗", "周六两点徐汇店两个人"]), + faq("顾问联系时效", [ + "客户问顾问什么时候联系时,不能承诺24小时、当天、最晚次日上午,除非门店配置明确。", + "回答:我已记录您的联系方式和到店意向,会转给门店顾问确认;具体联系时间以门店工作安排为准。您也可以补充方便接听的时间段,我一起备注。", + ], ["留电话后多久联系", "顾问什么时候打电话", "会有人联系我吗", "多久回电"]), + faq("库存现货边界", [ + "库存是实时信息,聊天里不得直接承诺现货、有货、可当天送。", + "回答:这款有对应规格,但今天是否有现货、最快配送和安装时间,需要门店顾问按规格实时确认。可以留下联系方式和目标尺寸,我帮您记录给顾问。", + ], ["今天有现货吗", "能不能马上送", "1.8米有没有货", "最快什么时候送"]), + faq("退换试睡边界", [ + "客户问不合适能不能退、有没有试睡时,不要直接承诺30天试睡、无条件退换。", + "回答:部分活动或指定订单可能会有体验/换购权益,但退换条件需要以购买合同、订单条款和门店售后政策为准。我可以帮您把顾虑记录给顾问,到店前先确认清楚。", + ], ["不合适能退吗", "有没有30天试睡", "能保证退吗", "睡着不舒服怎么办"]), + faq("售后异响投诉", [ + "客户说床垫异响、翻身咯吱响、生气、投诉时,先安抚,不要先替产品排除责任。", + "标准话术:真的抱歉影响您休息了。异响原因需要售后结合订单、产品型号、床架/排骨架和现场情况检查确认,我先帮您记录售后诉求并转人工顾问。您留下的电话我已记录,还可以补充购买时间、型号和异响位置。", + "禁止说通常不是床垫问题、马上上门、彻底解决、一定退换或赔付。", + ], ["床垫咯吱响怎么办", "我要投诉售后", "电话是让人工联系我", "床垫异响质量问题"]), + faq("人工转接和投诉升级", [ + "客户明确说人工、真人、投诉、平台投诉、12315、差评时,应优先转人工或提示已记录人工诉求。", + "如果客户已留下手机号,要确认已记录,不要再说“你可以留下手机号”。", + "表达方式:我已记录您的人工/投诉诉求和联系方式,会转给门店顾问或售后继续确认;处理结论以订单和售后检测为准。", + ], ["转人工", "我要真人", "没人处理我投诉", "电话给你了"]), + faq("闲聊和不耐烦", [ + "客户问你是谁:先回答身份,我是慕小眠,慕斯寝具在线睡眠顾问,可以帮您挑床垫、电动床、预约试躺,也能把价格、库存、售后问题转给门店顾问。", + "客户问会不会写诗:可以轻松接一句,不要生硬拒绝,然后拉回睡眠顾问职责。", + "客户说怎么没反应:先道歉并说我在,然后继续处理上一条需求,不要重复长篇介绍。", + ], ["你是谁", "你会写诗吗", "你听得懂吗", "怎么没反应"]), + faq("竞品对比和怕被忽悠", [ + "客户说怕被忽悠、是不是噱头、和别家有什么区别时,不要攻击竞品。", + "回答重点:建议用试躺指标判断,不听概念。看腰部是否悬空、肩臀是否压迫、翻身是否费力、边缘支撑是否稳定、起身是否轻松。", + "可以说慕斯门店会让您对比云感和脊护两种睡感,您自己身体感受最重要。", + ], ["护脊是不是噱头", "怕被忽悠", "和喜临门哪个好", "怎么判断不是营销"]), +] + +function faq(question, lines, similarQuestions) { + return { + question, + answer: lines.join("\n"), + similarQuestions, + remark: "Codex realistic muse seed", + } +} + +function relativeDate(days) { + const date = new Date() + date.setDate(date.getDate() + days) + return date.toISOString().slice(0, 10) +} + +async function request(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }) + const text = await response.text() + let body + try { + body = text ? JSON.parse(text) : {} + } catch { + body = { raw: text } + } + if (!response.ok || body.success === false) { + throw new Error(`${options.method || "GET"} ${path} failed ${response.status}: ${text.slice(0, 800)}`) + } + return body.data ?? body +} + +function authHeaders(token) { + return { Authorization: `Bearer ${token}` } +} + +function pickToken(login) { + return login.token || login.accessToken || login.access_token || "" +} + +async function login() { + const ret = await request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: adminUsername, password: adminPassword }), + }) + const token = pickToken(ret) + if (!token) throw new Error("login did not return token") + return token +} + +async function listAll(path, token, body = {}) { + const data = await request(path, { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ page: 1, limit: 200, ...body }), + }) + return data.results || [] +} + +async function upsertProduct(token, product) { + const existing = (await listAll("/api/dashboard/product/list", token, { keyword: product.name })) + .find((item) => item.name === product.name) + const payload = { ...product, knowledgeBaseId } + if (existing?.id) { + await request("/api/dashboard/product/update", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ ...payload, id: existing.id }), + }) + await request("/api/dashboard/product/reindex", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ id: existing.id }), + }) + return { action: "updated", id: existing.id, name: product.name } + } + const created = await request("/api/dashboard/product/create", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify(payload), + }) + return { action: "created", id: created.id, name: product.name } +} + +async function upsertPromotion(token, promotion) { + const existing = (await listAll("/api/dashboard/promotion/list", token, { keyword: promotion.name })) + .find((item) => item.name === promotion.name) + const payload = { ...promotion, knowledgeBaseId } + if (existing?.id) { + await request("/api/dashboard/promotion/update", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ ...payload, id: existing.id }), + }) + await request("/api/dashboard/promotion/reindex", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ id: existing.id }), + }) + return { action: "updated", id: existing.id, name: promotion.name } + } + const created = await request("/api/dashboard/promotion/create", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify(payload), + }) + return { action: "created", id: created.id, name: promotion.name } +} + +async function upsertFAQ(token, item) { + const query = new URLSearchParams({ + knowledgeBaseId: String(knowledgeBaseId), + question: item.question, + limit: "200", + }) + const list = await request(`/api/dashboard/knowledge-faq/list?${query.toString()}`, { + headers: authHeaders(token), + }) + const existing = (list.results || []).find((faqItem) => faqItem.question === item.question) + const payload = { knowledgeBaseId, directoryId: 0, ...item } + if (existing?.id) { + await request("/api/dashboard/knowledge-faq/update", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ ...payload, id: existing.id }), + }) + return { action: "updated", id: existing.id, question: item.question } + } + const created = await request("/api/dashboard/knowledge-faq/create", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify(payload), + }) + return { action: "created", id: created.id, question: item.question } +} + +async function main() { + console.log(`Seeding realistic Muse data into ${baseUrl}`) + const token = await login() + await request("/api/dashboard/digital-store/apply_template", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ templateCode: "muse_bedding" }), + }) + await request("/api/dashboard/product/seed_muse", { + method: "POST", + headers: authHeaders(token), + body: "{}", + }) + await request("/api/dashboard/promotion/seed_muse", { + method: "POST", + headers: authHeaders(token), + body: "{}", + }) + + const productResults = [] + for (const product of products) productResults.push(await upsertProduct(token, product)) + + const promotionResults = [] + for (const promotion of promotions) promotionResults.push(await upsertPromotion(token, promotion)) + + const faqResults = [] + for (const item of faqs) faqResults.push(await upsertFAQ(token, item)) + + await request("/api/dashboard/digital-store/sync_knowledge", { + method: "POST", + headers: authHeaders(token), + body: "{}", + }) + const runtime = await request("/api/dashboard/digital-store/ensure_runtime", { + method: "POST", + headers: authHeaders(token), + body: "{}", + }) + + console.log(JSON.stringify({ + products: productResults, + promotions: promotionResults, + faqs: { + total: faqResults.length, + created: faqResults.filter((item) => item.action === "created").length, + updated: faqResults.filter((item) => item.action === "updated").length, + }, + runtime: { + ready: runtime.ready, + webChannelCode: runtime.webChannelCode, + missingSteps: runtime.missingSteps || [], + }, + }, null, 2)) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/web/app/dashboard/_components/dashboard-home.tsx b/web/app/dashboard/_components/dashboard-home.tsx index 32ecc364..c4e10e35 100644 --- a/web/app/dashboard/_components/dashboard-home.tsx +++ b/web/app/dashboard/_components/dashboard-home.tsx @@ -1,17 +1,33 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { RefreshCwIcon } from "lucide-react" +import { ClipboardIcon, RefreshCwIcon, SendIcon } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent } from "@/components/ui/card" import { useI18n } from "@/i18n/provider" import { + createKnowledgeFAQDraftFromRetrieveLog, + type KnowledgeFAQ, +} from "@/lib/api/admin" +import { + fetchABTestReport, + fetchAIQualityReport, + fetchBusinessTrendReport, + fetchDailyBusinessReport, fetchDashboardOverview, + fetchSalesFunnelReport, + sendDailyBusinessReport, + type DashboardABTestReport, + type DashboardAIQualityReport, + type DashboardBusinessTrendReport, + type DashboardDailyBusinessReport, type DashboardOverview, type DashboardRange, + type DashboardSalesFunnelReport, } from "@/lib/api/dashboard" import { SummaryCards } from "./summary-cards" import { TrendPanel } from "./trend-panel" @@ -34,10 +50,1296 @@ function LoadingCards() { ) } +function DigitalStorePanel({ stats }: { stats: DashboardOverview["digitalStoreStats"] }) { + const t = useI18n() + const metrics = [ + { + label: t("dashboardHome.digitalStoreTodayConsultations"), + value: stats.todayConsultations, + }, + { + label: t("dashboardHome.digitalStoreTodayLeads"), + value: stats.todayLeads, + }, + { + label: t("dashboardHome.digitalStoreLeadConversionRate"), + value: `${stats.leadConversionRate.toFixed(1)}%`, + }, + { + label: t("dashboardHome.digitalStoreHighIntent"), + value: stats.todayHighIntentLeads, + }, + { + label: t("dashboardHome.digitalStoreAppointments"), + value: stats.todayAppointmentLeads, + }, + { + label: t("dashboardHome.digitalStoreConverted"), + value: stats.todayConvertedLeads, + }, + { + label: t("dashboardHome.digitalStorePendingFollowUp"), + value: stats.pendingFollowUpLeads, + }, + { + label: t("dashboardHome.digitalStoreActiveProducts"), + value: stats.activeProducts, + }, + { + label: t("dashboardHome.digitalStoreActivePromotions"), + value: stats.activePromotions, + }, + { + label: t("dashboardHome.digitalStoreHandoffs"), + value: stats.todayHandoffs, + }, + ] + + return ( + + +
+
+
{t("dashboardHome.digitalStoreTitle")}
+
{stats.summary}
+
+
+
+
+ {metrics.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
{t("dashboardHome.digitalStoreTopProducts")}
+
+ {stats.topLeadProducts.length > 0 ? ( + stats.topLeadProducts.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
{t("dashboardHome.digitalStoreNoTopProducts")}
+ )} +
+
+
+
+
+ ) +} + +function buildDailyReportText(report: DashboardDailyBusinessReport) { + const sections = [ + report.summary, + "", + "经营亮点", + ...report.highlights.map((item) => `- ${item}`), + "", + "成交结果", + `- 今日成交线索:${report.convertedCount}`, + "", + "热门咨询问题", + ...(report.topQuestions.length > 0 + ? report.topQuestions.map((item) => `- ${item.name}(${item.count}次)`) + : ["- 暂无"]), + "", + "未解决问题", + ...(report.unansweredQuestions.length > 0 + ? report.unansweredQuestions.map((item) => `- ${item.name}(${item.count}次)`) + : ["- 暂无"]), + "", + "跟进风险", + `- 逾期未跟进:${report.overdueFollowUpCount}`, + `- 今日待跟进:${report.todayFollowUpCount}`, + `- 未排计划高意向/预约:${report.unscheduledHotLeads}`, + `- 未分配重点线索:${report.unassignedPriorityLeadCount}`, + "", + "预约风险", + `- 逾期未到店:${report.overdueAppointmentCount}`, + `- 今日预约:${report.todayAppointmentCount}`, + `- 未定时间:${report.unscheduledAppointmentCount}`, + "", + "售后/投诉风险", + `- 未处理工单:${report.pendingAfterSalesTicketCount}`, + `- 今日新增:${report.todayAfterSalesTicketCount}`, + `- 今日已处理:${report.todayHandledAfterSalesTicketCount}`, + ...(report.afterSalesTickets.length > 0 + ? report.afterSalesTickets.map((ticket) => { + const owner = ticket.currentAssigneeName || "未分配" + const progress = ticket.latestProgress + ? `|最近进展:${ticket.latestProgress}${ticket.latestProgressAt ? `(${ticket.latestProgressAt})` : ""}` + : "" + return `- ${ticket.ticketNo || `#${ticket.id}`}|${ticketStatusText(ticket.status)}|负责人:${owner}|${ticket.title}|${ticket.description || "暂无描述"}${progress}` + }) + : ["- 暂无"]), + "", + "AI 质量反馈", + `- 今日反馈:${report.aiFeedbackCount}`, + `- 点赞:${report.aiFeedbackLikeCount}`, + `- 负反馈:${report.aiFeedbackNegativeCount}`, + `- 负反馈率:${report.aiFeedbackNegativeRate.toFixed(1)}%`, + ...(report.topAiFeedbackReasons.length > 0 + ? report.topAiFeedbackReasons.map((item) => `- ${item.name}(${item.count}次)`) + : ["- 暂无负反馈原因"]), + ...(report.recentNegativeAiFeedbacks.length > 0 + ? [ + "- 最近负反馈明细:", + ...report.recentNegativeAiFeedbacks.map((item) => { + const reason = item.feedbackReason || item.feedbackTypeName || "未填写原因" + const question = item.question || `检索日志 #${item.retrieveLogId}` + return ` - ${item.createdAt}|${item.feedbackTypeName}|${question}|${reason}|${knowledgeRetrieveLogHref(item.retrieveLogId, item.knowledgeBaseId)}` + }), + ] + : []), + "", + "待确认 FAQ 草稿", + `- 待确认数量:${report.pendingFaqDraftCount}`, + ...(report.pendingFaqDrafts.length > 0 + ? report.pendingFaqDrafts.map((item) => { + return `- ${item.createdAt}|${item.question || `FAQ #${item.id}`}|${knowledgeFaqHref(item.id, item.knowledgeBaseId)}` + }) + : ["- 暂无"]), + "", + "跟进建议", + ...report.followUpSuggestions.map((item) => `- ${item}`), + "", + "优先跟进名单", + ...(report.priorityFollowUps.length > 0 + ? report.priorityFollowUps.map((lead) => { + const contact = lead.phone || lead.wechat || "-" + const owner = lead.ownerUserName || "未分配" + return `- ${lead.customerName || "未命名客户"}|${contact}|${followUpStateText(lead.followUpState)}|${lead.nextFollowUpAt || "未设置"}|负责人:${owner}|${lead.demandSummary || "暂无需求摘要"}` + }) + : ["- 暂无"]), + "", + "知识库建议", + ...report.knowledgeSuggestions.map((item) => `- ${item}`), + ] + if (report.highIntentLeads.length > 0) { + sections.push( + "", + "高意向线索", + ...report.highIntentLeads.map((lead) => { + const contact = lead.phone || lead.wechat || "-" + const appointment = [ + lead.appointmentAt, + lead.appointmentTimeText, + lead.appointmentStore, + lead.appointmentPeople > 0 ? `${lead.appointmentPeople}人` : "", + ].filter(Boolean).join(" / ") + return `- ${lead.customerName || "未命名客户"}|${contact}|${lead.interestedProducts || "未填写产品"}|${appointment || "未填写预约"}|${lead.demandSummary || "暂无需求摘要"}` + }) + ) + } + return sections.join("\n") +} + +function knowledgeRetrieveLogHref(retrieveLogId: number, knowledgeBaseId?: number) { + const params = new URLSearchParams({ + tab: "retrieveLogs", + retrieveLogId: String(retrieveLogId), + }) + if (knowledgeBaseId) { + params.set("knowledgeBaseId", String(knowledgeBaseId)) + } + return `/dashboard/knowledge?${params.toString()}` +} + +function knowledgeFaqHref(faqId: number, knowledgeBaseId?: number) { + const params = new URLSearchParams({ + tab: "documents", + faqId: String(faqId), + }) + if (knowledgeBaseId) { + params.set("knowledgeBaseId", String(knowledgeBaseId)) + } + return `/dashboard/knowledge?${params.toString()}` +} + +function followUpStateText(value?: string) { + if (value === "overdue") return "已逾期" + if (value === "today") return "今日跟进" + if (value === "scheduled") return "已安排" + if (value === "unscheduled") return "未设置" + return value || "-" +} + +function ticketStatusText(value?: string) { + if (value === "pending") return "待处理" + if (value === "in_progress") return "处理中" + if (value === "done") return "已处理" + return value || "-" +} + +function ticketStatusVariant(value?: string) { + if (value === "pending") return "destructive" as const + if (value === "in_progress") return "default" as const + return "outline" as const +} + +function followUpStateVariant(value?: string) { + if (value === "overdue") return "destructive" as const + if (value === "today") return "default" as const + if (value === "unscheduled") return "secondary" as const + return "outline" as const +} + +function qualityTodoVariant(level?: string) { + if (level === "error") return "destructive" as const + if (level === "warning") return "secondary" as const + return "outline" as const +} + +function AIQualityPanel({ report }: { report: DashboardAIQualityReport }) { + const [creatingDraftId, setCreatingDraftId] = useState(null) + const [createdDrafts, setCreatedDrafts] = useState>({}) + const handleCreateFAQDraft = async (retrieveLogId: number) => { + if (!retrieveLogId || creatingDraftId) return + setCreatingDraftId(retrieveLogId) + try { + const draft = await createKnowledgeFAQDraftFromRetrieveLog({ + retrieveLogId, + remark: "由首页 AI 质检待办生成的待确认 FAQ 草稿", + }) + setCreatedDrafts((prev) => ({ ...prev, [retrieveLogId]: draft })) + toast.success(draft.status === 0 ? "已复用现有 FAQ" : "FAQ 草稿已生成,确认答案后启用") + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成 FAQ 草稿失败") + } finally { + setCreatingDraftId(null) + } + } + const metrics = [ + { label: "检索次数", value: report.retrieveTotal }, + { label: "命中率", value: `${report.retrieveHitRate.toFixed(1)}%` }, + { label: "风险回复", value: report.riskAnswerCount }, + { label: "负反馈率", value: `${report.negativeFeedbackRate.toFixed(1)}%` }, + { label: "待确认 FAQ", value: report.pendingFaqDraftCount }, + ] + return ( + + +
+
+
AI 质检待办
+
+ {report.startDate} 至 {report.endDate},集中处理未命中、兜底、风控和负反馈。 +
+
+ + 待办 {report.todoTotal} + +
+
+ {metrics.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
+
待处理事项
+
+ {report.todos.length > 0 ? ( + report.todos.map((todo) => ( + +
+ {todo.title} + {todo.count} +
+
+ {todo.description} +
+
+ {todo.actionLabel || "去处理"} +
+
+ )) + ) : ( +
当前周期暂无 AI 质检待办。
+ )} +
+
+
+
知识修正建议
+
+ {report.knowledgeSuggestions.map((item) => ( +
{item}
+ ))} +
+
+
+
+
+
高频待处理问题
+ + {report.pendingQuestionGroups.length} + +
+
+ {report.pendingQuestionGroups.length > 0 ? ( + report.pendingQuestionGroups.slice(0, 6).map((item) => { + const createdDraft = createdDrafts[item.latestRetrieveLogId] + return ( +
+
+ {item.question} + {item.count} +
+
+ {item.noAnswerCount > 0 ? 无答案 {item.noAnswerCount} : null} + {item.fallbackCount > 0 ? 兜底 {item.fallbackCount} : null} + {item.blockedCount > 0 ? 风控 {item.blockedCount} : null} + {item.negativeFeedbackCount > 0 ? 负反馈 {item.negativeFeedbackCount} : null} +
+
+ + {item.actionLabel || "查看日志"} + + +
+
+ ) + }) + ) : ( +
当前周期暂无高频待处理问题。
+ )} +
+
+
+
+
未解决问题
+
+ {report.unansweredQuestions.length > 0 ? ( + report.unansweredQuestions.slice(0, 5).map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
暂无未解决问题
+ )} +
+
+
+
负反馈原因
+
+ {report.topNegativeReasons.length > 0 ? ( + report.topNegativeReasons.slice(0, 5).map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
暂无负反馈原因
+ )} +
+
+
+
风险回复样本
+
+ {report.recentRiskAnswerSamples.length > 0 ? ( + report.recentRiskAnswerSamples.slice(0, 5).map((item) => ( + +
+ + {item.question || `检索日志 #${item.id}`} + + {item.answerStatusName} +
+
+ 命中 {item.hitCount} / 分数 {item.topScore} +
+
+ )) + ) : ( +
暂无风险回复样本
+ )} +
+
+
+
+
+ ) +} + +function formatMinutes(value: number) { + if (!Number.isFinite(value) || value <= 0) return "-" + if (value < 60) return `${Math.round(value)} 分钟` + return `${(value / 60).toFixed(1)} 小时` +} + +function SalesFunnelPanel({ report }: { report: DashboardSalesFunnelReport }) { + return ( + + +
+
+
线索转化漏斗
+
+ {report.startDate} 至 {report.endDate},咨询到留资 {report.leadConversionRate.toFixed(1)}%,留资到成交 {report.closedConversionRate.toFixed(1)}%。 +
+
+
+ + 未分配 {report.unassignedTotal} + + + 逾期 {report.overdueFollowUpTotal} + +
+
+ +
+
+
顾问效率
+
+ + + + + + + + + + + + + + + + {report.advisorStats.length > 0 ? ( + report.advisorStats.map((item) => ( + + + + + + + + + + + + )) + ) : ( + + + + )} + +
顾问线索跟进逾期成交无效无效原因转化率首跟进
{item.ownerUserName || "未分配"}{item.assignedLeadCount}{item.followUpCount} + + {item.overdueFollowUpCount} + + {item.convertedLeadCount}{item.invalidLeadCount} +
+ {item.invalidReasons.length > 0 + ? item.invalidReasons.map((reason) => `${reason.name} ${reason.count}`).join(" / ") + : "-"} +
+
{item.conversionRate.toFixed(1)}%{formatMinutes(item.averageFirstFollowUpMinutes)}
+ 当前周期暂无线索数据 +
+
+
+
+
漏斗建议
+
+ {report.suggestions.map((item) => ( +
{item}
+ ))} +
+
+
无效原因 Top
+
+ {report.invalidReasons.length > 0 ? ( + report.invalidReasons.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
当前周期暂无无效原因
+ )} +
+
+
+
+
+
+ ) +} + +function BusinessTrendPanel({ report }: { report: DashboardBusinessTrendReport }) { + const latestSeries = report.series.slice(-7) + const maxConversation = Math.max(1, ...latestSeries.map((item) => item.conversationCount)) + const handleCopyReport = async () => { + try { + await navigator.clipboard.writeText(report.reportMarkdown) + toast.success("经营趋势复盘已复制") + } catch (error) { + toast.error(error instanceof Error ? error.message : "复制经营趋势复盘失败") + } + } + const metrics = [ + { label: "咨询", value: report.conversationTotal }, + { label: "留资", value: report.leadTotal }, + { label: "留资率", value: `${report.leadConversionRate.toFixed(1)}%` }, + { label: "高意向", value: report.highIntentTotal }, + { label: "预约", value: report.appointmentTotal }, + { label: "到店", value: report.visitedTotal }, + { label: "成交", value: report.convertedTotal }, + { label: "转人工", value: report.handoffTotal }, + { label: "负反馈", value: report.negativeFeedbackTotal }, + ] + const rankingGroups = [ + { title: "热门产品", items: report.topProducts }, + { title: "来源渠道", items: report.topChannels }, + { title: "高频问题", items: report.topQuestions }, + { title: "未解决问题", items: report.topUnansweredQuestions }, + ] + + return ( + + +
+
+
经营趋势复盘
+
+ {report.startDate} 至 {report.endDate},按产品、渠道、问题和顾问汇总数字店长经营趋势。 +
+
+
+ + + FAQ 草稿 {report.pendingFaqDraftCount} + + + 负反馈 {report.negativeFeedbackTotal} + +
+
+
+ {metrics.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
+
最近 7 天趋势
+
+ {latestSeries.map((item) => { + const height = Math.max(8, Math.round((item.conversationCount / maxConversation) * 56)) + return ( +
+
{item.date.slice(5)}
+
+
+
+
+
+
+
+
咨询 {item.conversationCount}
+
留资 {item.leadCount}
+
到店 {item.visitedCount}
+
成交 {item.convertedCount}
+
+
+ ) + })} +
+
+
+
经营建议
+
+ {report.suggestions.map((item) => ( +
{item}
+ ))} +
+
+
+
+ {rankingGroups.map((group) => ( +
+
{group.title}
+
+ {group.items.length > 0 ? ( + group.items.slice(0, 5).map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
暂无数据
+ )} +
+
+ ))} +
+ + + ) +} + +function abQualityRiskLabel(level: string) { + switch (level) { + case "high": + return "高风险" + case "medium": + return "中风险" + case "low": + return "低风险" + case "sample_low": + return "样本少" + default: + return "观察中" + } +} + +function ABTestPanel({ report }: { report: DashboardABTestReport }) { + return ( + + +
+
+
A/B 话术效果
+
+ {report.startDate} 至 {report.endDate},按 sourceChannel 对比不同入口、开场白或预约引导版本。 +
+
+
+ 版本 {report.variantTotal} + 线索 {report.leadTotal} + {report.feedbackTotal > 0 ? ( + 0 ? "secondary" : "outline"}> + AI负反馈 {report.negativeFeedbackRate.toFixed(1)}% + + ) : null} +
+
+
+ + + + + + + + + + + + + + + + + {report.variants.length > 0 ? ( + report.variants.map((item) => ( + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
版本线索高意向率预约率到店率成交率无效率质量风险主产品建议
+
{item.variantName || item.variantCode}
+
{item.variantCode}
+
{item.leadCount}{item.highIntentRate.toFixed(1)}%{item.appointmentRate.toFixed(1)}%{item.visitRate.toFixed(1)}%{item.conversionRate.toFixed(1)}%{item.invalidRate.toFixed(1)}% +
+ + {abQualityRiskLabel(item.qualityRiskLevel)} + +
+ {item.qualityRiskReason || "暂无明显风险"} +
+
+
{item.topProduct || "-"} + {item.recommendedAction} +
+ 当前周期暂无可对比线索。 +
+
+
+ {report.suggestions.map((item) => ( +
+ {item} +
+ ))} +
+
+
+ ) +} + +function ChannelSourcePanel({ report }: { report: DashboardABTestReport }) { + const topChannels = report.variants.slice(0, 6) + const bestChannel = topChannels[0] + + return ( + + +
+
+
渠道来源统计
+
+ {report.startDate} 至 {report.endDate},按 sourceChannel 统计不同入口带来的线索质量。 +
+
+
+ 来源 {report.variantTotal} + 线索 {report.leadTotal} +
+
+ + {bestChannel ? ( +
+
+
+
当前主力来源:{bestChannel.variantName}
+
+ 主产品 {bestChannel.topProduct || "-"} / 留资 {bestChannel.leadCount} +
+
+
+
+
{bestChannel.highIntentRate.toFixed(1)}%
+
高意向
+
+
+
{bestChannel.appointmentRate.toFixed(1)}%
+
预约
+
+
+
{bestChannel.visitRate.toFixed(1)}%
+
到店
+
+
+
{bestChannel.conversionRate.toFixed(1)}%
+
成交
+
+
+
+
+ ) : null} + +
+ {topChannels.length > 0 ? ( + topChannels.map((item) => { + const share = report.leadTotal > 0 ? (item.leadCount / report.leadTotal) * 100 : 0 + return ( +
+
+
+
{item.variantName}
+
{item.variantCode}
+
+ = 40 ? "destructive" : "secondary"}> + {share.toFixed(1)}% + +
+
+
+
+
+
+
{item.leadCount}
+
线索
+
+
+
{item.highIntentCount}
+
高意向
+
+
+
{item.appointmentCount}
+
预约
+
+
+
{item.visitedCount}
+
到店
+
+
+
{item.convertedCount}
+
成交
+
+
+ {item.invalidCount > 0 ? ( +
+ 无效 {item.invalidCount},无效率 {item.invalidRate.toFixed(1)}% +
+ ) : null} +
+ ) + }) + ) : ( +
+ 暂无来源数据。建议官网、企微、广告落地页传入不同 sourceChannel。 +
+ )} +
+ + + ) +} + +function DailyBusinessReportPanel({ report }: { report: DashboardDailyBusinessReport }) { + const t = useI18n() + const [sending, setSending] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(buildDailyReportText(report)) + toast.success(t("dashboardHome.dailyReportCopied")) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("dashboardHome.dailyReportCopyFailed")) + } + } + + const handleSend = async () => { + if (sending) return + setSending(true) + try { + const result = await sendDailyBusinessReport(report.reportDate) + if (result.sent) { + toast.success(result.message || "日报已发送") + } else { + toast.info(result.message || "日报未发送") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "发送日报失败") + } finally { + setSending(false) + } + } + + return ( + + +
+
+
{t("dashboardHome.dailyReportTitle")}
+
{report.summary}
+
+
+ + +
+
+
+
+
+
{t("dashboardHome.dailyReportPriorityFollowUps")}
+
+ + {t("dashboardHome.dailyReportOverdue")} {report.overdueFollowUpCount} + + + {t("dashboardHome.dailyReportDueToday")} {report.todayFollowUpCount} + + + {t("dashboardHome.dailyReportUnscheduledHot")} {report.unscheduledHotLeads} + + + {t("dashboardHome.dailyReportUnassignedPriorityLeads")} {report.unassignedPriorityLeadCount} + + + {t("dashboardHome.dailyReportOverdueAppointments")} {report.overdueAppointmentCount} + + + {t("dashboardHome.dailyReportTodayAppointments")} {report.todayAppointmentCount} + + + {t("dashboardHome.dailyReportUnscheduledAppointments")} {report.unscheduledAppointmentCount} + + + {t("dashboardHome.dailyReportPendingAfterSalesTickets")} {report.pendingAfterSalesTicketCount} + + + {t("dashboardHome.dailyReportTodayAfterSalesTickets")} {report.todayAfterSalesTicketCount} + + + {t("dashboardHome.dailyReportHandledAfterSalesTickets")} {report.todayHandledAfterSalesTicketCount} + + + {t("dashboardHome.dailyReportNegativeAIFeedback")} {report.aiFeedbackNegativeCount} + + + {t("dashboardHome.dailyReportPendingFAQDrafts")} {report.pendingFaqDraftCount} + +
+
+ +
+
+
+
{t("dashboardHome.dailyReportAfterSalesTickets")}
+
+ + {t("dashboardHome.dailyReportPendingAfterSalesTickets")} {report.pendingAfterSalesTicketCount} + + + {t("dashboardHome.dailyReportTodayAfterSalesTickets")} {report.todayAfterSalesTicketCount} + + + {t("dashboardHome.dailyReportHandledAfterSalesTickets")} {report.todayHandledAfterSalesTicketCount} + +
+
+ +
+
+
+
{t("dashboardHome.dailyReportAIFeedback")}
+
+ + {t("dashboardHome.dailyReportTotalAIFeedback")} {report.aiFeedbackCount} + + + {t("dashboardHome.dailyReportPositiveAIFeedback")} {report.aiFeedbackLikeCount} + + + {t("dashboardHome.dailyReportNegativeAIFeedback")} {report.aiFeedbackNegativeCount} + + + {report.aiFeedbackNegativeRate.toFixed(1)}% + +
+
+
+ {report.topAiFeedbackReasons.length > 0 ? ( + report.topAiFeedbackReasons.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
+ {t("dashboardHome.dailyReportNoAIFeedbackReasons")} +
+ )} +
+ + +
+
+
{t("dashboardHome.dailyReportHighlights")}
+
+ {report.highlights.map((item) => ( +
{item}
+ ))} +
+
+
+
{t("dashboardHome.dailyReportFollowUps")}
+
+ {report.followUpSuggestions.map((item) => ( +
{item}
+ ))} +
+
+
+
{t("dashboardHome.dailyReportKnowledge")}
+
+ {report.knowledgeSuggestions.map((item) => ( +
{item}
+ ))} +
+
+
+
+
+
{t("dashboardHome.dailyReportTopQuestions")}
+
+ {report.topQuestions.length > 0 ? ( + report.topQuestions.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
{t("dashboardHome.dailyReportNoQuestions")}
+ )} +
+
+
+
{t("dashboardHome.dailyReportUnansweredQuestions")}
+
+ {report.unansweredQuestions.length > 0 ? ( + report.unansweredQuestions.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
{t("dashboardHome.dailyReportNoUnanswered")}
+ )} +
+
+
+ {report.highIntentLeads.length > 0 ? ( +
+
{t("dashboardHome.dailyReportHighIntentLeads")}
+
+ {report.highIntentLeads.slice(0, 4).map((lead) => ( +
+
+ {lead.customerName || t("dashboardHome.dailyReportUnknownCustomer")} +
+
+ {lead.phone || lead.wechat || t("dashboardHome.dailyReportNoContact")} +
+
+ {lead.interestedProducts || t("dashboardHome.dailyReportNoProduct")} +
+
+ {[lead.appointmentAt, lead.appointmentTimeText, lead.appointmentStore].filter(Boolean).join(" / ") || "-"} +
+
+ ))} +
+
+ ) : null} +
+
+ ) +} + export function DashboardHome() { const t = useI18n() const [range, setRange] = useState("7d") const [data, setData] = useState(null) + const [dailyReport, setDailyReport] = useState(null) + const [aiQualityReport, setAIQualityReport] = useState(null) + const [salesFunnelReport, setSalesFunnelReport] = useState(null) + const [businessTrendReport, setBusinessTrendReport] = useState(null) + const [abTestReport, setABTestReport] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) @@ -49,8 +1351,32 @@ export function DashboardHome() { setLoading(true) } try { - const result = await fetchDashboardOverview(nextRange) - setData(result) + const [overviewResult, reportResult, aiQualityResult, salesFunnelResult, businessTrendResult, abTestResult] = + await Promise.allSettled([ + fetchDashboardOverview(nextRange), + fetchDailyBusinessReport(), + fetchAIQualityReport(nextRange), + fetchSalesFunnelReport(nextRange), + fetchBusinessTrendReport(nextRange), + fetchABTestReport(nextRange), + ]) + if (overviewResult.status === "fulfilled") { + setData(overviewResult.value) + } else { + setData(null) + toast.error(overviewResult.reason instanceof Error ? overviewResult.reason.message : t("dashboardHome.loadFailed")) + } + setDailyReport(reportResult.status === "fulfilled" ? reportResult.value : null) + setAIQualityReport(aiQualityResult.status === "fulfilled" ? aiQualityResult.value : null) + setSalesFunnelReport(salesFunnelResult.status === "fulfilled" ? salesFunnelResult.value : null) + setBusinessTrendReport(businessTrendResult.status === "fulfilled" ? businessTrendResult.value : null) + setABTestReport(abTestResult.status === "fulfilled" ? abTestResult.value : null) + const optionalResults = [reportResult, aiQualityResult, salesFunnelResult, businessTrendResult, abTestResult] + optionalResults.forEach((result) => { + if (result.status === "rejected") { + console.warn("[dashboard] optional report failed", result.reason) + } + }) } catch (error) { toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed")) } finally { @@ -105,6 +1431,20 @@ export function DashboardHome() { <> + + + {salesFunnelReport ? : null} + + {businessTrendReport ? : null} + + {abTestReport ? : null} + + {abTestReport ? : null} + + {aiQualityReport ? : null} + + {dailyReport ? : null} + { - setOrigin(window.location.origin) - }, []) + const [origin] = useState(() => + typeof window === "undefined" ? "" : window.location.origin + ) const accessUrl = useMemo(() => { if (!origin || !channelId) { @@ -879,11 +877,9 @@ function WebAccessGuide({ channelId }: { channelId: string }) { function WechatMPAccessGuide({ channelId }: { channelId: string }) { const t = useI18n() - const [origin, setOrigin] = useState("") - - useEffect(() => { - setOrigin(window.location.origin) - }, []) + const [origin] = useState(() => + typeof window === "undefined" ? "" : window.location.origin + ) const menuUrl = useMemo(() => { if (!origin || !channelId) { diff --git a/web/app/dashboard/conversations/_components/conversation-workbench.tsx b/web/app/dashboard/conversations/_components/conversation-workbench.tsx index be489792..224edb53 100644 --- a/web/app/dashboard/conversations/_components/conversation-workbench.tsx +++ b/web/app/dashboard/conversations/_components/conversation-workbench.tsx @@ -2,11 +2,13 @@ import { ArrowRightLeftIcon, + BotMessageSquareIcon, ChevronLeft, ChevronRight, ChevronsUpDown, CircleUserRoundIcon, CircleXIcon, + ClipboardListIcon, FilePlus2Icon, Menu, MoreHorizontalIcon, @@ -42,6 +44,8 @@ import { type AgentConversationFilterKey, useAgentConversationsStore, } from "@/lib/stores/agent-conversations"; +import { generateConversationFollowUpAdvice } from "@/lib/api/admin"; +import { resumeAIConversation } from "@/lib/api/agent"; import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog"; import { ChatPanel } from "./chat-panel"; import { ConversationInfoPanel } from "./conversation-info-panel"; @@ -84,6 +88,8 @@ export function ConversationWorkbench() { const [transferOpen, setTransferOpen] = useState(false); const [closeOpen, setCloseOpen] = useState(false); const [createTicketOpen, setCreateTicketOpen] = useState(false); + const [resumingAI, setResumingAI] = useState(false); + const [generatingFollowUpAdvice, setGeneratingFollowUpAdvice] = useState(false); const sidebarPanelRef = useRef(null); const infoPanelRef = useRef(null); const filterContainerRef = useRef(null); @@ -119,6 +125,11 @@ export function ConversationWorkbench() { agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ?? agentConversationFilterOptions[0]; const getFilterLabel = (labelKey: string) => t(labelKey); + const canResumeAI = + Boolean(conversation) && + conversation?.serviceMode === 3 && + conversation.status !== 1 && + conversation.status !== 4; useEffect(() => { void loadConversations().catch((error) => { @@ -134,6 +145,39 @@ export function ConversationWorkbench() { }); } + async function handleResumeAI() { + if (!conversation || resumingAI || !canResumeAI) return; + setResumingAI(true); + try { + await resumeAIConversation(conversation.id, t("conversation.resumeAIReason")); + toast.success(t("conversation.resumeAISuccess")); + await loadConversations(); + await loadMessages(conversation.id, { forceLoading: true, reset: true }); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("conversation.resumeAIFailed")); + } finally { + setResumingAI(false); + } + } + + async function handleCopyFollowUpAdvice() { + if (!conversation || generatingFollowUpAdvice) return; + setGeneratingFollowUpAdvice(true); + try { + const advice = await generateConversationFollowUpAdvice(conversation.id); + if (!advice.copyText) { + toast.error("暂时没有可复制的跟进摘要"); + return; + } + await navigator.clipboard.writeText(advice.copyText); + toast.success(advice.leadId ? "线索跟进摘要已复制" : "会话跟进摘要已复制"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成跟进摘要失败"); + } finally { + setGeneratingFollowUpAdvice(false); + } + } + const handleSidebarToggle = () => { const panel = sidebarPanelRef.current; if (!panel) { @@ -369,6 +413,13 @@ export function ConversationWorkbench() { {t("conversation.createTicket")} + void handleCopyFollowUpAdvice()} + disabled={!conversation || generatingFollowUpAdvice} + > + + {generatingFollowUpAdvice ? "生成中..." : "复制跟进摘要"} + setTransferOpen(true)} disabled={!conversation || conversation.status !== 3} @@ -376,6 +427,13 @@ export function ConversationWorkbench() { {t("conversation.transferConversation")} + void handleResumeAI()} + disabled={!canResumeAI || resumingAI} + > + + {resumingAI ? t("conversation.resumingAI") : t("conversation.resumeAI")} + setCloseOpen(true)} disabled={!conversation || conversation.status === 4} diff --git a/web/app/dashboard/customers/page.tsx b/web/app/dashboard/customers/page.tsx index a188224f..acab601c 100644 --- a/web/app/dashboard/customers/page.tsx +++ b/web/app/dashboard/customers/page.tsx @@ -2,6 +2,7 @@ import { BanIcon, CheckCircle2Icon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { type CustomerFormSavePayload } from "@/components/customer-form"; import { @@ -34,6 +35,8 @@ function getGenderText(gender: number, t: TFunction) { export default function DashboardCustomersPage() { const t = useI18n(); + const searchParams = useSearchParams(); + const initialKeyword = searchParams.get("keyword") ?? ""; const [companyOptions, setCompanyOptions] = useState([ { value: "0", label: t("customer.allCompanies") }, ]); @@ -88,7 +91,7 @@ export default function DashboardCustomersPage() { name: "keyword", label: t("customer.columnName"), placeholder: t("customer.keywordPlaceholder"), - defaultValue: "", + defaultValue: initialKeyword, trim: true, className: "w-full sm:w-72", }, @@ -123,7 +126,7 @@ export default function DashboardCustomersPage() { className: "w-full sm:w-36", }, ], - [companyOptions, genderOptions, listStatusOptions, t], + [companyOptions, genderOptions, initialKeyword, listStatusOptions, t], ); const columns = useMemo[]>( diff --git a/web/app/dashboard/digital-store/page.tsx b/web/app/dashboard/digital-store/page.tsx new file mode 100644 index 00000000..b9074e8d --- /dev/null +++ b/web/app/dashboard/digital-store/page.tsx @@ -0,0 +1,242 @@ +"use client" + +import { useEffect, useState } from "react" +import { RefreshCwIcon, SaveIcon, SparklesIcon } from "lucide-react" +import { toast } from "sonner" + +import { DashboardPage, DashboardToolbar } from "@/components/dashboard-page" +import { Button } from "@/components/ui/button" +import { Field, FieldContent, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + fetchDigitalStoreProfile, + saveDigitalStoreProfile, + seedMuseDigitalStoreProfile, + syncDigitalStoreKnowledge, + type DigitalStoreProfile, +} from "@/lib/api/digital-store" + +type FormState = Omit< + DigitalStoreProfile, + "knowledgeFAQId" | "templateCode" | "templateVersion" | "templateAppliedAt" | "updatedAt" +> + +const emptyForm: FormState = { + brandName: "", + industry: "", + storeName: "", + storeAddress: "", + businessHours: "", + contactPhone: "", + serviceWechat: "", + enterpriseWebhookUrl: "", + aiManagerName: "", + aiPersona: "", + replyStyle: "", + forbiddenClaims: "", + handoffPolicy: "", + appointmentPolicy: "", + knowledgeBaseId: 0, + initialized: false, +} + +function toForm(profile: DigitalStoreProfile): FormState { + return { + ...emptyForm, + ...profile, + knowledgeBaseId: Number(profile.knowledgeBaseId || 0), + initialized: Boolean(profile.initialized), + } +} + +export default function DashboardDigitalStorePage() { + const [form, setForm] = useState(emptyForm) + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + async function loadProfile() { + setLoading(true) + try { + const next = await fetchDigitalStoreProfile() + setProfile(next) + setForm(toForm(next)) + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载店长配置失败") + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadProfile() + }, []) + + function patchForm(key: K, value: FormState[K]) { + setForm((current) => ({ ...current, [key]: value })) + } + + async function handleSave() { + if (saving) return + setSaving(true) + try { + const next = await saveDigitalStoreProfile({ + ...form, + knowledgeBaseId: Number(form.knowledgeBaseId || 0), + initialized: true, + }) + setProfile(next) + setForm(toForm(next)) + toast.success("店长配置已保存,并同步到知识库") + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存店长配置失败") + } finally { + setSaving(false) + } + } + + async function handleSeedMuse() { + if (saving) return + setSaving(true) + try { + const next = await seedMuseDigitalStoreProfile() + setProfile(next) + setForm(toForm(next)) + toast.success("已导入慕斯寝具样板配置") + } catch (error) { + toast.error(error instanceof Error ? error.message : "导入样板失败") + } finally { + setSaving(false) + } + } + + async function handleSyncKnowledge() { + if (saving) return + setSaving(true) + try { + const next = await syncDigitalStoreKnowledge() + setProfile(next) + setForm(toForm(next)) + toast.success("店长配置已同步到知识库") + } catch (error) { + toast.error(error instanceof Error ? error.message : "同步知识库失败") + } finally { + setSaving(false) + } + } + + return ( + + + + + + + } + > +
+

AI数字店长配置

+

+ {profile?.knowledgeFAQId + ? `已同步为 FAQ #${profile.knowledgeFAQId}` + : "配置品牌、门店、人设、预约和转人工规则"} +

+
+
+ +
+
+
+ patchForm("brandName", value)} placeholder="慕斯寝具" /> + patchForm("industry", value)} placeholder="家居寝具" /> + patchForm("storeName", value)} placeholder="城市旗舰店" /> + patchForm("businessHours", value)} placeholder="周一至周日 10:00-21:00" /> + patchForm("storeAddress", value)} placeholder="门店详细地址" className="md:col-span-2" /> + patchForm("contactPhone", value)} placeholder="400 或门店电话" /> + patchForm("serviceWechat", value)} placeholder="微信号" /> + patchForm("enterpriseWebhookUrl", value)} placeholder="后续用于通知顾问" className="md:col-span-2" /> + patchForm("knowledgeBaseId", Number(value || 0))} placeholder="留空自动使用第一个 FAQ 知识库" type="number" /> + patchForm("aiManagerName", value)} placeholder="慕小眠" /> +
+
+ +
+
+ patchForm("aiPersona", value)} rows={4} /> + patchForm("replyStyle", value)} rows={4} /> + patchForm("appointmentPolicy", value)} rows={4} /> + patchForm("handoffPolicy", value)} rows={4} /> + patchForm("forbiddenClaims", value)} rows={4} /> +
+
+
+
+ ) +} + +function TextField({ + label, + value, + onChange, + placeholder, + type = "text", + className, +}: { + label: string + value: string + onChange: (value: string) => void + placeholder?: string + type?: string + className?: string +}) { + return ( + + {label} + + onChange(event.target.value)} + /> + + + ) +} + +function TextareaField({ + label, + value, + onChange, + rows, +}: { + label: string + value: string + onChange: (value: string) => void + rows: number +}) { + return ( + + {label} + +