diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5a8c13d Binary files /dev/null and b/.DS_Store differ diff --git a/docs/im/IM-联系我们字典配置.sql b/docs/im/IM-联系我们字典配置.sql new file mode 100644 index 0000000..a8a7afa --- /dev/null +++ b/docs/im/IM-联系我们字典配置.sql @@ -0,0 +1,19 @@ +-- ---------------------------------------------------------------- +-- IM「联系我们」配置:字典管理新增 im_contact_us 字典类型及默认字典数据 +-- 用途:移动端通过 im-server 接口 /v1/im/contact-us 读取展示客服邮箱 +-- 依赖:无需前置 SQL +-- 幂等:重复执行不产生重复行 +-- 说明:邮箱的最终值请在管理后台「系统管理 → 字典管理 → 联系我们」中维护 +-- ---------------------------------------------------------------- +USE hotgo; +SET @now := NOW(); + +-- 字典类型:联系我们(挂载在系统枚举 sys 下) +INSERT INTO `hg_sys_dict_type` (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT (SELECT `id` FROM `hg_sys_dict_type` WHERE `type` = 'sys' LIMIT 1), '联系我们', 'im_contact_us', 0, '移动端联系我们配置', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_contact_us'); + +-- 字典数据:客服邮箱 +INSERT INTO `hg_sys_dict_data` (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '客服邮箱', 'support@example.com', 'string', 'im_contact_us', '', 1, 10, '移动端展示的客服邮箱', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_sys_dict_data` WHERE `type` = 'im_contact_us' AND `label` = '客服邮箱'); diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 7692a16..c001eef 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -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:"获取内容审核服务配置"` } diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index 2532d9d..7d8f2c8 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -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 { diff --git a/server/internal/logic/sys/dict_data.go b/server/internal/logic/sys/dict_data.go index c85fad9..e214a54 100644 --- a/server/internal/logic/sys/dict_data.go +++ b/server/internal/logic/sys/dict_data.go @@ -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) diff --git a/server/internal/logic/sys/im_invite.go b/server/internal/logic/sys/im_invite.go index d2f93e5..619e6fe 100644 --- a/server/internal/logic/sys/im_invite.go +++ b/server/internal/logic/sys/im_invite.go @@ -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 }) diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index e43b70c..293142c 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -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) diff --git a/server/internal/model/input/sysin/im_invite.go b/server/internal/model/input/sysin/im_invite.go index b7cb29f..c3150ce 100644 --- a/server/internal/model/input/sysin/im_invite.go +++ b/server/internal/model/input/sysin/im_invite.go @@ -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 } diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index 2759726..d4249fb 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -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:"昵称"` diff --git a/web/src/views/im/registrationInvite/BindUserModal.vue b/web/src/views/im/registrationInvite/BindUserModal.vue index 7ddac28..9f8d661 100644 --- a/web/src/views/im/registrationInvite/BindUserModal.vue +++ b/web/src/views/im/registrationInvite/BindUserModal.vue @@ -132,7 +132,8 @@ } submitting.value = true; try { - await BindUser({ tenantId: tenantId.value, id: inviteId.value, userId: Number(selectedUserId.value) }); + // 雪花ID超出 JS Number 安全整数范围,保持字符串传输避免精度丢失 + await BindUser({ tenantId: tenantId.value, id: inviteId.value, userId: selectedUserId.value }); message.success('绑定成功'); showModal.value = false; emit('reload'); diff --git a/web/src/views/im/registrationInvite/CreateModal.vue b/web/src/views/im/registrationInvite/CreateModal.vue index 45708ec..5aac578 100644 --- a/web/src/views/im/registrationInvite/CreateModal.vue +++ b/web/src/views/im/registrationInvite/CreateModal.vue @@ -4,6 +4,23 @@ + +
+ + 搜索 +
+
+ + {{ boundUser.nickname || boundUser.username || boundUser.phone || '--' }}({{ boundUser.userId }}) + 清除 +
+
@@ -24,7 +41,9 @@ import { reactive, ref } from 'vue'; import { useMessage } from 'naive-ui'; import { BatchCreate, Create } from '@/api/im/registrationInvite'; + import { List as UserList } from '@/api/imUser'; import { localRfc3339 } from './model'; + import defaultAvatar from '@/assets/images/schoolboy.png'; const emit = defineEmits(['reload']); const message = useMessage(); @@ -33,11 +52,15 @@ const formRef = ref(); const tenantId = ref(0); const isBatch = ref(false); + const bindUserInput = ref(''); + const searching = ref(false); + const boundUser = ref(null); const formValue = reactive({ quantity: 100, expiresAt: Date.now() + 7 * 24 * 3600 * 1000, remark: '', idempotencyKey: '', + boundUserId: '', }); const rules = { @@ -52,6 +75,9 @@ formValue.expiresAt = Date.now() + 7 * 24 * 3600 * 1000; formValue.remark = ''; formValue.idempotencyKey = crypto.randomUUID(); + formValue.boundUserId = ''; + bindUserInput.value = ''; + boundUser.value = null; showModal.value = true; } @@ -59,17 +85,61 @@ showModal.value = false; } + async function searchUser() { + const keyword = bindUserInput.value.trim(); + if (!keyword) { + message.warning('请输入用户ID/编号'); + return; + } + searching.value = true; + try { + const res = await UserList({ + tenantId: tenantId.value, + userId: keyword, + keyword: '', + page: 1, + perPage: 1, + }); + const user = res?.list?.[0]; + if (!user) { + message.warning('未找到该用户,将不绑定用户'); + boundUser.value = null; + formValue.boundUserId = ''; + return; + } + boundUser.value = user; + // 雪花ID超出 JS Number 安全整数范围,必须保持字符串传输,避免精度丢失 + formValue.boundUserId = String(user.userId || ''); + message.success('已选中用户'); + } catch (error: any) { + message.error(error?.toString() || '用户查询失败'); + boundUser.value = null; + formValue.boundUserId = ''; + } finally { + searching.value = false; + } + } + + function clearBoundUser() { + boundUser.value = null; + formValue.boundUserId = ''; + bindUserInput.value = ''; + } + async function submit() { await formRef.value?.validate(); loading.value = true; try { - const params = { + const params: any = { tenantId: tenantId.value, expiresAt: localRfc3339(formValue.expiresAt), remark: formValue.remark, idempotencyKey: formValue.idempotencyKey, }; if (isBatch.value) { + if (formValue.boundUserId) { + params.userId = formValue.boundUserId; + } await BatchCreate({ ...params, quantity: formValue.quantity }); } else { await Create(params); @@ -87,3 +157,22 @@ defineExpose({ openModal }); + + diff --git a/web/src/views/imSecurityPolicy/index.vue b/web/src/views/imSecurityPolicy/index.vue index ed89b1b..8bcc47c 100644 --- a/web/src/views/imSecurityPolicy/index.vue +++ b/web/src/views/imSecurityPolicy/index.vue @@ -515,6 +515,8 @@ windowMinutes: lockoutForm.value.windowMinutes, }); message.success('已保存,配置即时生效'); + policyVisible.value = false; + reloadAll(); } catch (e: any) { message.error(e.message || '保存失败'); } finally {