From 131168c700f4106c4f63b23cbc896a6747ebdd02 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 14 Oct 2026 16:46:20 +0800 Subject: [PATCH] feat: add customer service IM bindings --- .../internal/dao/internal/customer_service.go | 6 +++++ server/internal/logic/sys/customer_service.go | 21 +++++++++++++--- server/internal/model/do/customer_service.go | 3 +++ .../internal/model/entity/customer_service.go | 3 +++ server/internal/model/input/sysin/trade.go | 16 +++++++++--- .../20261013_007_customer_service_im.sql | 25 +++++++++++++++++++ .../20261014_008_member_im_password.sql | 4 +++ .../views/trade/customer-service/index.vue | 17 ++++++++++--- web/src/views/trade/customer-service/model.ts | 4 +-- 9 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 server/storage/data/migrations/20261013_007_customer_service_im.sql create mode 100644 server/storage/data/migrations/20261014_008_member_im_password.sql diff --git a/server/internal/dao/internal/customer_service.go b/server/internal/dao/internal/customer_service.go index f9c4e48..d46abec 100644 --- a/server/internal/dao/internal/customer_service.go +++ b/server/internal/dao/internal/customer_service.go @@ -31,6 +31,9 @@ type CustomerServiceColumns struct { ServiceUrl string // 客服跳转URL BackupContact string // 备用联系方式 SessionMapping string // 第三方会话映射 + ImTenantCode string // IM租户代码 + ImUserId string // IM客服用户ID + ImUsername string // IM客服登录名 Status string // 状态 Sort string // 排序 Remark string // 备注 @@ -54,6 +57,9 @@ var customerServiceColumns = CustomerServiceColumns{ ServiceUrl: "service_url", BackupContact: "backup_contact", SessionMapping: "session_mapping", + ImTenantCode: "im_tenant_code", + ImUserId: "im_user_id", + ImUsername: "im_username", Status: "status", Sort: "sort", Remark: "remark", diff --git a/server/internal/logic/sys/customer_service.go b/server/internal/logic/sys/customer_service.go index 2b458b7..f116329 100644 --- a/server/internal/logic/sys/customer_service.go +++ b/server/internal/logic/sys/customer_service.go @@ -70,21 +70,36 @@ func (s *sSysCustomerService) Edit(ctx context.Context, in *sysin.CustomerServic if count > 0 { return gerror.New("客服编码已存在") } - data := g.Map{c.ServiceCode: in.ServiceCode, c.Name: in.Name, c.AvatarUrl: in.AvatarUrl, c.WorkTime: in.WorkTime, c.OnlineStatus: in.OnlineStatus, c.ServiceUrl: in.ServiceUrl, c.BackupContact: in.BackupContact, c.SessionMapping: in.SessionMapping, c.Status: in.Status, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: contexts.GetUserId(ctx)} + if in.ImUserId != "" { + count, err = s.model(ctx).Where(c.ImTenantCode, in.ImTenantCode).Where(c.ImUserId, in.ImUserId).WhereNot(c.Id, in.Id).Count() + if err != nil { + return err + } + if count > 0 { + return gerror.New("IM客服账号已被其他席位绑定") + } + } + data := g.Map{c.ServiceCode: in.ServiceCode, c.Name: in.Name, c.AvatarUrl: in.AvatarUrl, c.WorkTime: in.WorkTime, c.OnlineStatus: in.OnlineStatus, c.ServiceUrl: in.ServiceUrl, c.BackupContact: in.BackupContact, c.SessionMapping: in.SessionMapping, c.ImTenantCode: in.ImTenantCode, c.ImUserId: gdb.Raw("NULL"), c.ImUsername: gdb.Raw("NULL"), c.Status: in.Status, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: contexts.GetUserId(ctx)} + if in.ImUserId != "" { + data[c.ImUserId] = in.ImUserId + } + if in.ImUsername != "" { + data[c.ImUsername] = in.ImUsername + } if in.Id > 0 { if _, err = s.model(ctx).WherePri(in.Id).Data(data).Update(); err != nil { return gerror.Wrap(err, "更新客服配置失败") } } else { data[c.CreatedBy] = contexts.GetUserId(ctx) - result, e := s.model(ctx).Fields(c.ServiceCode, c.Name, c.AvatarUrl, c.WorkTime, c.OnlineStatus, c.ServiceUrl, c.BackupContact, c.SessionMapping, c.Status, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert() + result, e := s.model(ctx).Fields(c.ServiceCode, c.Name, c.AvatarUrl, c.WorkTime, c.OnlineStatus, c.ServiceUrl, c.BackupContact, c.SessionMapping, c.ImTenantCode, c.ImUserId, c.ImUsername, c.Status, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert() if e != nil { return gerror.Wrap(e, "创建客服配置失败") } id, _ := result.LastInsertId() in.Id = uint64(id) } - return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "customer_service", Action: "customer_service.edit", TargetType: "customer_service", TargetId: in.Id, Before: before, After: g.Map{"name": in.Name, "workTime": in.WorkTime, "onlineStatus": in.OnlineStatus, "serviceUrl": in.ServiceUrl, "status": in.Status}, Reason: in.Remark}) + return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "customer_service", Action: "customer_service.edit", TargetType: "customer_service", TargetId: in.Id, Before: before, After: g.Map{"name": in.Name, "workTime": in.WorkTime, "onlineStatus": in.OnlineStatus, "serviceUrl": in.ServiceUrl, "imTenantCode": in.ImTenantCode, "imUserId": in.ImUserId, "status": in.Status}, Reason: in.Remark}) } func (s *sSysCustomerService) Status(ctx context.Context, in *sysin.CustomerServiceStatusInp) error { diff --git a/server/internal/model/do/customer_service.go b/server/internal/model/do/customer_service.go index 40569c8..d287998 100644 --- a/server/internal/model/do/customer_service.go +++ b/server/internal/model/do/customer_service.go @@ -23,6 +23,9 @@ type CustomerService struct { ServiceUrl any // 客服跳转URL BackupContact any // 备用联系方式 SessionMapping *gjson.Json // 第三方会话映射 + ImTenantCode any // IM租户代码 + ImUserId any // IM客服用户ID + ImUsername any // IM客服登录名 Status any // 状态 Sort any // 排序 Remark any // 备注 diff --git a/server/internal/model/entity/customer_service.go b/server/internal/model/entity/customer_service.go index 8970063..3f81f42 100644 --- a/server/internal/model/entity/customer_service.go +++ b/server/internal/model/entity/customer_service.go @@ -21,6 +21,9 @@ type CustomerService struct { ServiceUrl string `json:"serviceUrl" orm:"service_url" description:"客服跳转URL"` BackupContact string `json:"backupContact" orm:"backup_contact" description:"备用联系方式"` SessionMapping *gjson.Json `json:"sessionMapping" orm:"session_mapping" description:"第三方会话映射"` + ImTenantCode string `json:"imTenantCode" orm:"im_tenant_code" description:"IM租户代码"` + ImUserId string `json:"imUserId" orm:"im_user_id" description:"IM客服用户ID"` + ImUsername string `json:"imUsername" orm:"im_username" description:"IM客服登录名"` Status int `json:"status" orm:"status" description:"状态"` Sort int `json:"sort" orm:"sort" description:"排序"` Remark string `json:"remark" orm:"remark" description:"备注"` diff --git a/server/internal/model/input/sysin/trade.go b/server/internal/model/input/sysin/trade.go index e5b6ef1..345dd51 100644 --- a/server/internal/model/input/sysin/trade.go +++ b/server/internal/model/input/sysin/trade.go @@ -302,19 +302,27 @@ type CustomerServiceEditInp struct { AvatarUrl string `json:"avatarUrl" v:"max-length:1000#头像地址不能超过1000个字符"` WorkTime string `json:"workTime" v:"required|max-length:200#工作时间不能为空|工作时间不能超过200个字符"` OnlineStatus int `json:"onlineStatus" v:"required|in:1,2#在线状态不能为空|在线状态不合法"` - ServiceUrl string `json:"serviceUrl" v:"required|max-length:1000#客服链接不能为空|客服链接不能超过1000个字符"` + ServiceUrl string `json:"serviceUrl" v:"max-length:1000#客服链接不能超过1000个字符"` BackupContact string `json:"backupContact" v:"required|max-length:500#备用联系方式不能为空|备用联系方式不能超过500个字符"` SessionMapping *gjson.Json `json:"sessionMapping"` + ImTenantCode string `json:"imTenantCode" v:"required|max-length:64#IM租户代码不能为空|IM租户代码不能超过64个字符"` + ImUserId string `json:"imUserId" v:"max-length:64#IM用户ID不能超过64个字符"` + ImUsername string `json:"imUsername" v:"max-length:64#IM登录名不能超过64个字符"` Status int `json:"status" d:"1" v:"required|in:1,2#状态不能为空|状态不合法"` Sort int `json:"sort"` Remark string `json:"remark" v:"max-length:500#备注不能超过500个字符"` } func (in *CustomerServiceEditInp) Filter(ctx context.Context) error { - u, err := url.Parse(in.ServiceUrl) - if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" { - return gerror.New("客服链接必须是有效的 HTTPS 地址") + if strings.TrimSpace(in.ServiceUrl) != "" { + u, err := url.Parse(in.ServiceUrl) + if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" { + return gerror.New("客服链接必须是有效的 HTTPS 地址") + } } + in.ImTenantCode = strings.ToLower(strings.TrimSpace(in.ImTenantCode)) + in.ImUserId = strings.TrimSpace(in.ImUserId) + in.ImUsername = strings.ToLower(strings.TrimSpace(in.ImUsername)) if in.OnlineStatus != consts.CustomerServiceOnline && in.OnlineStatus != consts.CustomerServiceOffline { return gerror.New("客服在线状态不合法") } diff --git a/server/storage/data/migrations/20261013_007_customer_service_im.sql b/server/storage/data/migrations/20261013_007_customer_service_im.sql new file mode 100644 index 0000000..11d1181 --- /dev/null +++ b/server/storage/data/migrations/20261013_007_customer_service_im.sql @@ -0,0 +1,25 @@ +-- 客服系统接入自建 IM:客服席位绑定、C 端会员 IM 身份映射。 +-- im_user_id 使用 NULL 表示尚未绑定,避免唯一索引把多个空值误判为重复。 + +ALTER TABLE `hg_customer_service` + ADD COLUMN `im_tenant_code` varchar(64) NOT NULL DEFAULT 'allrich' COMMENT 'IM租户代码' AFTER `session_mapping`, + ADD COLUMN `im_user_id` varchar(64) DEFAULT NULL COMMENT 'IM客服用户ID' AFTER `im_tenant_code`, + ADD COLUMN `im_username` varchar(64) DEFAULT NULL COMMENT 'IM客服登录名' AFTER `im_user_id`, + ADD UNIQUE KEY `uk_customer_service_im_user` (`im_tenant_code`, `im_user_id`); + +CREATE TABLE IF NOT EXISTS `hg_app_member_im_identity` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '业务租户ID', + `member_id` bigint unsigned NOT NULL COMMENT '会员ID', + `im_tenant_code` varchar(64) NOT NULL COMMENT 'IM租户代码', + `im_user_id` varchar(64) NOT NULL COMMENT 'IM公开用户ID', + `im_username` varchar(64) NOT NULL COMMENT 'IM登录名', + `sync_status` tinyint NOT NULL DEFAULT 1 COMMENT '同步状态:1正常、2待重试、3失败', + `last_error` varchar(500) NOT NULL DEFAULT '' COMMENT '最近同步错误', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_member_im_identity_member` (`tenant_id`, `member_id`, `im_tenant_code`), + UNIQUE KEY `uk_member_im_identity_user` (`im_tenant_code`, `im_user_id`), + KEY `idx_member_im_identity_member` (`member_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员_IM身份映射'; diff --git a/server/storage/data/migrations/20261014_008_member_im_password.sql b/server/storage/data/migrations/20261014_008_member_im_password.sql new file mode 100644 index 0000000..8b32565 --- /dev/null +++ b/server/storage/data/migrations/20261014_008_member_im_password.sql @@ -0,0 +1,4 @@ +-- Olons 复用 IM 现有 Basic 登录协议,随机 IM 密码仅以 AES-GCM 密文保存。 + +ALTER TABLE `hg_app_member_im_identity` + ADD COLUMN `im_password_cipher` varchar(512) NOT NULL DEFAULT '' COMMENT 'IM登录密码密文' AFTER `im_username`; diff --git a/web/src/views/trade/customer-service/index.vue b/web/src/views/trade/customer-service/index.vue index 89cda4b..e8553c5 100644 --- a/web/src/views/trade/customer-service/index.vue +++ b/web/src/views/trade/customer-service/index.vue @@ -65,8 +65,19 @@ + + + + + + + + + + + - + @@ -140,11 +151,11 @@ onlineStatus: { required: true, type: 'number', message: '请选择在线状态', trigger: 'change' }, status: { required: true, type: 'number', message: '请选择状态', trigger: 'change' }, serviceUrl: { - required: true, pattern: /^https:\/\/.+/i, - message: '请输入有效的 HTTPS 地址', + message: '若填写,请输入有效的 HTTPS 地址', trigger: ['input', 'blur'], }, + imTenantCode: { required: true, message: '请输入 IM 租户代码', trigger: ['input', 'blur'] }, backupContact: { required: true, message: '请输入备用联系方式', trigger: ['input', 'blur'] }, }; const [register] = useForm({ diff --git a/web/src/views/trade/customer-service/model.ts b/web/src/views/trade/customer-service/model.ts index f2f59c8..1e8c40e 100644 --- a/web/src/views/trade/customer-service/model.ts +++ b/web/src/views/trade/customer-service/model.ts @@ -3,7 +3,7 @@ import { NTag } from 'naive-ui'; import { FormSchema } from '@/components/Form'; import { useDictStore } from '@/store/modules/dict'; const dict=useDictStore(); -export class State { id=0;serviceCode='';name='';avatarUrl='';workTime='09:00-18:00';onlineStatus=2;serviceUrl='https://';backupContact='';status=1;sort=0;remark=''; } +export class State { id=0;serviceCode='';name='';avatarUrl='';workTime='09:00-18:00';onlineStatus=2;serviceUrl='';backupContact='';imTenantCode='allrich';imUserId='';imUsername='';status=1;sort=0;remark=''; } export const schemas=ref([ {field:'name',component:'NInput',label:'客服名称',componentProps:{clearable:true}}, {field:'status',component:'NSelect',label:'状态',componentProps:{options:computed(()=>dict.getOptionUnRef('sys_normal_disable')),clearable:true}}, @@ -11,7 +11,7 @@ export const schemas=ref([ const tag=(type,value)=>h(NTag,{type:dict.getType(type,value),bordered:false},{default:()=>dict.getLabel(type,value)||'-'}); export const columns=[ {title:'编码',key:'serviceCode',width:130},{title:'客服名称',key:'name',width:140},{title:'工作时间',key:'workTime',width:150}, - {title:'在线状态',key:'onlineStatus',width:100,render:(r)=>tag('customerServiceOnline',r.onlineStatus)},{title:'跳转 URL',key:'serviceUrl',width:260,ellipsis:{tooltip:true}}, + {title:'在线状态',key:'onlineStatus',width:100,render:(r)=>tag('customerServiceOnline',r.onlineStatus)},{title:'IM账号',key:'imUsername',width:150,render:(r)=>r.imUsername||'未绑定'},{title:'IM用户ID',key:'imUserId',width:190,ellipsis:{tooltip:true}}, {title:'备用联系方式',key:'backupContact',width:220,ellipsis:{tooltip:true}},{title:'状态',key:'status',width:90,render:(r)=>tag('sys_normal_disable',r.status)}, {title:'排序',key:'sort',width:80},{title:'创建时间',key:'createdAt',width:180}, ];