feat: 新增IM联系我们字典配置,优化用户搜索与邀请绑定功能

1. 优化IM用户列表搜索逻辑,支持长数字用户ID精确匹配
2. 修复前端雪花ID精度丢失问题,改用字符串传输用户ID
3. 新增邀请码批量绑定用户功能,支持搜索绑定指定用户
4. 新增字典配置接口,新增联系我们字典SQL脚本
5. 完善字典数据变更事件推送,优化弹窗关闭与页面刷新逻辑
This commit is contained in:
Austin
2026-09-21 13:11:08 +08:00
parent 0381984939
commit b4174a6412
12 changed files with 215 additions and 10 deletions
@@ -62,6 +62,21 @@ type ThirdPartyRes struct {
Values map[string]string `json:"values" dc:"配置值"`
}
type DictionaryReq struct {
g.Meta `path:"/internal/im-config/dictionary/{type}" method:"get" tags:"IM内部配置" summary:"获取字典配置"`
Type string `json:"type" in:"path" dc:"字典类型"`
}
type DictionaryItemModel struct {
Label string `json:"label" dc:"展示名"`
Value string `json:"value" dc:"配置值"`
Sort int `json:"sort" dc:"排序"`
}
type DictionaryRes struct {
Items []*DictionaryItemModel `json:"items" dc:"字典项列表"`
}
type ModerationProviderReq struct {
g.Meta `path:"/internal/im-config/moderation/provider" method:"get" tags:"IM内部配置" summary:"获取内容审核服务配置"`
}
@@ -107,6 +107,30 @@ func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res *
return &api.ThirdPartyRes{Values: values}, nil
}
func (c *cConfig) Dictionary(ctx context.Context, req *api.DictionaryReq) (res *api.DictionaryRes, err error) {
dictType := strings.TrimSpace(req.Type)
if dictType == "" || dictType == consts.ImInternalSecretDictType {
return nil, gerror.New("字典类型不合法")
}
var rows []*entity.SysDictData
if err = dao.SysDictData.Ctx(ctx).
Where(dao.SysDictData.Columns().Type, dictType).
Where(dao.SysDictData.Columns().Status, consts.StatusEnabled).
Order(dao.SysDictData.Columns().Sort + " asc, " + dao.SysDictData.Columns().Id + " desc").
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "获取IM字典配置失败")
}
res = &api.DictionaryRes{Items: make([]*api.DictionaryItemModel, 0, len(rows))}
for _, row := range rows {
res.Items = append(res.Items, &api.DictionaryItemModel{
Label: row.Label,
Value: row.Value,
Sort: row.Sort,
})
}
return res, nil
}
func (c *cConfig) ModerationProvider(ctx context.Context, req *api.ModerationProviderReq) (res *api.ModerationProviderRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, defaultModerationTenantCode)
if err != nil {
+29
View File
@@ -13,6 +13,7 @@ import (
"hotgo/internal/library/dict"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -30,6 +31,7 @@ func init() {
// Delete 删除
func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error {
oldTypes := s.getTypesByIds(ctx, in.Id)
if count, err := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); err != nil {
return gerror.Wrap(err, consts.ErrorORM)
} else if count > 0 {
@@ -40,6 +42,7 @@ func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp)
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, oldTypes)
return nil
}
@@ -52,11 +55,13 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err
} else if count > 0 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
oldTypes := s.getTypesByIds(ctx, in.Id)
_, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataUpdateFields{}).WherePri(in.Id).Data(in).Update()
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, append(oldTypes, in.Type))
return nil
}
@@ -79,9 +84,33 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, []string{in.Type})
return nil
}
// getTypesByIds 查询指定字典数据ID的类型标识,用于变更后失效缓存
func (s *sSysDictData) getTypesByIds(ctx context.Context, ids interface{}) []string {
columns, err := dao.SysDictData.Ctx(ctx).Where(dao.SysDictData.Columns().Id, ids).Fields(dao.SysDictData.Columns().Type).Array()
if err != nil {
g.Log().Warningf(ctx, "查询字典类型失败 err:%+v", err)
return nil
}
return g.NewVar(columns).Strings()
}
// publishDictUpdated 发布字典变更事件,通知 im-server 实时失效字典配置缓存
func (s *sSysDictData) publishDictUpdated(ctx context.Context, types []string) {
for _, t := range types {
t = strings.TrimSpace(t)
if t == "" || t == consts.ImInternalSecretDictType {
continue
}
if err := publishImPolicyEvent(ctx, 0, "*", consts.ImConfigEventDictionaryUpdated, t, 0); err != nil {
g.Log().Warningf(ctx, "发布字典变更事件失败 type:%s err:%+v", t, err)
}
}
}
// List 获取列表
func (s *sSysDictData) List(ctx context.Context, in *sysin.DictDataListInp) (list []*sysin.DictDataListModel, totalCount int, err error) {
mod := dao.SysDictData.Ctx(ctx).WhereNot(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType)
+23 -6
View File
@@ -116,7 +116,7 @@ func (s *sSysImInvite) Create(ctx context.Context, in *sysin.ImInviteCreateInp)
expiresAt := in.ExpiresAt.Time.UTC().Round(time.Millisecond)
var item imInviteCodeResponse
if key := strings.TrimSpace(in.IdempotencyKey); key != "" {
batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key)
batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key, 0, "")
if createErr != nil {
err = createErr
} else if len(codes) > 0 {
@@ -150,15 +150,27 @@ func (s *sSysImInvite) BatchCreate(ctx context.Context, in *sysin.ImInviteBatchC
if err != nil {
return nil, err
}
var boundUserLabel string
if in.UserId > 0 {
tenant, terr := resolveImAdminTenant(ctx, scope.TenantId)
if terr != nil {
return nil, terr
}
label, lerr := inviteBoundUserLabel(ctx, tenant, in.UserId)
if lerr != nil {
return nil, lerr
}
boundUserLabel = label
}
key := strings.TrimSpace(in.IdempotencyKey)
batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key)
batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key, in.UserId, boundUserLabel)
result := "success"
if err != nil {
result = "failed"
} else {
res = convertInviteBatch(batch)
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark})
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark, "boundUserId": in.UserId})
return res, err
}
@@ -358,7 +370,7 @@ func createSingleInvite(ctx context.Context, db gdb.DB, tenantID, operatorID int
return imInviteCodeResponse{}, gerror.New("无法生成不重复的邀请码,请重试")
}
func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string) (*imInviteBatchResponse, []imInviteCodeResponse, error) {
func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string, boundUserID int64, boundUserLabel string) (*imInviteBatchResponse, []imInviteCodeResponse, error) {
if existing, err := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); err == nil {
if !sameInviteBatch(existing, quantity, expiresAt, remark) {
return nil, nil, gerror.New("幂等键已被其他批量生成请求使用")
@@ -402,11 +414,16 @@ func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int6
}
for i := range codes {
if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,'active',?,0,0,NULL,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now); txErr != nil {
(id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at,bound_user_id,bound_user_label)
VALUES(?,?,?,?,'active',?,0,0,NULL,?,?,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now, boundUserID, boundUserLabel); txErr != nil {
return txErr
}
codes[i].TenantID, codes[i].Status, codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, codes[i].CreatedAt = tenantID, "active", expiresAt, operatorID, remark, now
if boundUserID > 0 {
id := boundUserID
codes[i].BoundUserID = &id
codes[i].BoundUserLabel = boundUserLabel
}
}
return nil
})
+6 -1
View File
@@ -206,7 +206,12 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []
values.Set("user_ids", strings.Join(userIds, ","))
}
if in.UserId != "" {
values.Set("account_no", in.UserId)
// 长数字按用户ID(u.id)精确匹配,短数字按后台展示编号(account_no)匹配
if len(in.UserId) >= 12 {
values.Set("user_id", in.UserId)
} else {
values.Set("account_no", in.UserId)
}
}
if in.Nickname != "" {
values.Set("nickname", in.Nickname)
@@ -76,6 +76,7 @@ type ImInviteBatchCreateInp struct {
ExpiresAt *gtime.Time `json:"expiresAt" v:"required#到期时间不能为空" dc:"到期时间"`
Remark string `json:"remark" dc:"备注"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
UserId int64 `json:"userId" dc:"绑定C端用户ID,非必填,>0时为批次内所有邀请码绑定该用户"`
}
func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) {
@@ -92,6 +93,9 @@ func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) {
if len(in.IdempotencyKey) == 0 || len(in.IdempotencyKey) > 128 {
return gerror.New("批量生成必须提供不超过128个字符的幂等键")
}
if in.UserId < 0 {
return gerror.New("绑定用户ID不合法")
}
return
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
type ImUserListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId string `json:"userId" dc:"后台展示编号"`
UserId string `json:"userId" dc:"用户ID(长数字)或后台展示编号"`
GroupId int64 `json:"groupId" dc:"用户策略分组ID"`
Keyword string `json:"keyword" dc:"用户名、昵称、手机号或编号"`
Nickname string `json:"nickname" dc:"昵称"`