Compare commits

..
50 Commits
Author SHA1 Message Date
Austin 0596599db9 feat: add tenant bound restriction for IM dashboard and management pages
1. extract isTenantBound utility from user store logic
2. add disabled state to tenant select components across all IM pages
3. update policy tenant options logic to hide global option for bound tenants
4. add backend scope validation for IM admin operations
2026-09-22 11:34:40 +08:00
Austin 40ac52923e feat(imTenant): 新增商户管理员账号查看与密码修改功能
1.  新增编辑页管理员账号展示与密码修改字段,仅编辑态可见
2.  新增商户管理员密码重置逻辑,完善编辑场景密码校验
3.  补充超管可见控制:业务库、连接串、管理员信息非超管脱敏
4.  修复schema未使用响应式值的问题
2026-09-21 18:44:37 +08:00
Austin c9673ca871 feat: 新增商户管理员账号自动创建功能,优化租户权限控制
1.  后端实现租户创建时自动绑定创建管理员账号,支持超管/普通管理员权限区分
2.  前端适配超管权限控制业务库名、连接串等敏感字段展示与编辑
3.  优化租户数据库回退逻辑,未配置独立库时自动回落默认库
4.  调整租户列表与详情页的权限数据脱敏逻辑
2026-09-21 17:50:38 +08:00
Austin 197c6c86f4 fix: 修复多页面租户未就绪时表格提前请求问题,优化登录审计相关文案与列宽
1. 为imSensitiveWord、imUserPolicyGroup、imSecurityPolicy页面添加租户ID校验,避免表格挂载时因租户默认值未就绪发起无效请求
2. 更新imLoginFailureLog、imUserLoginHistory的搜索占位符与账号列宽
3. 修正后端登录审计相关结构体的字段注释描述
2026-09-21 16:25:12 +08:00
Austin a2f328f97e style(im/registrationInvite): 调整绑定用户提示的插槽封装与样式
将绑定用户未验证提示调整为插槽形式,并移除冗余的样式配置
2026-09-21 14:57:49 +08:00
Austin c4ab970fb7 fix(im/registrationInvite): add input verification for bound user search
1. add computed state to check if bind user input is unverified
2. disable confirm button and show warning tip when input is unverified
3. track last searched keyword to validate user input
4. add submission guard to prevent unvalidated submissions
2026-09-21 14:45:10 +08:00
Austin 8fad32e396 docs: update IM login lock error prompt copy and description
修正登录锁定错误提示文案,补充完整的时长格式化规则,更新示例文案匹配新规则
2026-09-21 13:57:53 +08:00
Austin 6f331a07fc style(imTenant): adjust action column width to 320px
widen the operation column to fit longer action buttons properly
2026-09-21 13:27:52 +08:00
Austin b4174a6412 feat: 新增IM联系我们字典配置,优化用户搜索与邀请绑定功能
1. 优化IM用户列表搜索逻辑,支持长数字用户ID精确匹配
2. 修复前端雪花ID精度丢失问题,改用字符串传输用户ID
3. 新增邀请码批量绑定用户功能,支持搜索绑定指定用户
4. 新增字典配置接口,新增联系我们字典SQL脚本
5. 完善字典数据变更事件推送,优化弹窗关闭与页面刷新逻辑
2026-09-21 13:11:08 +08:00
Austin 0381984939 Merge branch 'dev' of https://git.118im.com/im_team/im_manager into dev 2026-09-21 09:28:09 +08:00
Austin 6348ace38e feat(im security): add login failure lockout configuration feature
1. add APIs for login lockout config get and save
2. add backend service and controller for login lockout config
3. refactor frontend to separate login lockout config from main policy
4. update default login lockout policy values
5. add permission sql and docking documentation
2026-09-21 09:28:03 +08:00
richjohnrambo e8dd1eaa7d feat: 增加商户消息加密密钥管理 2026-09-20 23:43:33 +08:00
richjohnrambo 42cfe232fe fix: IM设置菜单更名为平台设置 2026-09-20 22:43:57 +08:00
richjohnrambo 4ae69706a5 fix: 区分IM系统设置菜单标题 2026-09-20 22:31:54 +08:00
richjohnrambo 3ef328a5a3 fix: 隐藏内置超管时保留角色树 2026-09-20 22:03:51 +08:00
richjohnrambo 2b93d27a4e fix: 角色列表隐藏内置超管 2026-09-20 21:53:38 +08:00
richjohnrambo 97990785cb fix: 平台超管权限走角色配置 2026-09-20 21:50:43 +08:00
richjohnrambo b68c048383 fix: 平台超管可管理后台角色 2026-09-20 21:48:10 +08:00
richjohnrambo f154b9e3c3 fix: 按角色规范后台用户商户绑定 2026-09-20 21:03:54 +08:00
richjohnrambo e2ca0607af fix: 平台超管拥有全部商户数据范围 2026-09-20 20:05:08 +08:00
richjohnrambo 6afeb65b91 feat: add tenant realname review policy 2026-09-20 18:31:46 +08:00
richjohnrambo e5ab79c40d fix: 拆分商户新增和编辑权限 2026-09-20 17:08:50 +08:00
Austin 8675fe333d feat(imLoginFailureLog): 用户登录密码错误次数锁定与解锁能力(管理端)
- 安全策略登录限频由 JSON 文本改为结构化表单(次数/锁定分钟/窗口分钟),编辑校验同步扩展
- 失败日志新增解锁入口:/imLoginFailureLog/unlock 经 internal 通道调用 im-server 释放锁定
- 失败日志模型补充 accountHash 字段,解锁按钮按 risk_action=account_locked 展示
- 租户业务库初始化模板补充 im_login_lockouts 等表;新增解锁按钮菜单权限 SQL
2026-09-20 17:04:19 +08:00
richjohnrambo fd4ff85191 fix: 限制IM租户列表为当前租户 2026-09-20 16:53:38 +08:00
richjohnrambo 6bf364ec0b fix: 修复养成管理接口参数过滤 2026-09-20 16:53:38 +08:00
Austin 47ed097923 refactor(imWorkOrder): 优化工单分类和目标类型的展示逻辑
将工单分类和目标类型的格式化逻辑抽离到model文件中,统一处理字典映射展示,替换原有的硬编码空值占位,同时修复列表页和详情页的展示不一致问题
2026-09-20 10:53:41 +08:00
Austin e5cfc80916 chore(imFile): 临时隐藏用途相关功能
注释掉页面、表单和列表中的用途相关代码,后续需要时可恢复
2026-09-20 10:44:27 +08:00
richjohnrambo c7265252d8 feat: 完善养成管理后台功能 2026-09-19 17:07:47 +08:00
richjohnrambo f6766d3dbd feat: 新增养成管理后台 2026-09-19 14:36:30 +08:00
richjohnrambo 7c8b4d9b7c feat: add friend add and curfew controls 2026-09-18 10:55:33 +08:00
richjohnrambo 348cbcaf96 fix: allow multiple config api service names 2026-09-17 22:55:21 +08:00
richjohnrambo 2a35cb4358 fix: update uid encryption key seed 2026-09-17 16:59:47 +08:00
richjohnrambo be6c5b525c fix: seed uid encryption config 2026-09-17 15:56:23 +08:00
richjohnrambo 9ed8aaf142 refactor: reuse global aliyun moderation config 2026-09-17 00:25:04 +08:00
richjohnrambo 4ed54ca392 test: initialize admin member service in scope tests 2026-09-16 23:45:21 +08:00
richjohnrambo 5173bc0aad fix: return default moderation policy when unpublished 2026-09-16 23:30:10 +08:00
richjohnrambo fec2a43b9c feat: add tenant content moderation controls 2026-09-16 22:21:42 +08:00
Austin 32491e9cdd fix: repair healthz mysql check and im tenant creation
- healthz: Scan(new(int)) is not supported by GoFrame, use .One() so
  the mysql check no longer reports a false failure
- im_tenant: set updated_by on insert for both im_tenant and
  im_tenant_config, otherwise new tenant creation fails with
  "Field 'updated_by' doesn't have a default value"
- gitignore: exclude .trae/ local debugging artifacts
2026-09-16 18:45:37 +08:00
richjohnrambo fe5c7af7e2 fix: show bound tenant in im menus 2026-09-16 16:54:12 +08:00
richjohnrambo 156e8b9c9f fix: align workbench default secret 2026-09-16 16:05:39 +08:00
richjohnrambo f8aaae42ff fix: use workbench service dns 2026-09-16 15:41:32 +08:00
richjohnrambo 82379b8823 fix: configure workbench service secret fallback 2026-09-16 15:21:28 +08:00
richjohnrambo 3e40554229 fix: fallback workbench admin secret 2026-09-16 14:28:42 +08:00
richjohnrambo e159f9a88e feat: proxy workbench admin through service 2026-09-16 14:03:58 +08:00
richjohnrambo c258e0473b fix: resolve bound im tenant for company admins 2026-09-16 13:19:55 +08:00
richjohnrambo 5dbbf39cce chore: update test environment defaults 2026-09-15 11:59:14 +08:00
richjohnrambo 50a2781b23 fix: avoid gf cli install in docker build 2026-09-14 14:27:23 +08:00
richjohnrambo a6f7ac41b0 fix: remove duplicate IM frontend API exports 2026-09-14 14:13:39 +08:00
richjohnrambo c1a6a41575 fix: update admin test environment config 2026-09-14 14:06:41 +08:00
Your NameandClaude Sonnet 5 f44e8adb72 ci: bring k3s deploy workflow over from main
This commit only ever landed on main (from when main was still the
default branch); dev's copy was still the stale blue-green version.
Bringing main's file over as-is before making dev the default branch,
so neither branch ends up missing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:28:55 +08:00
202 changed files with 9526 additions and 1660 deletions
Vendored
BIN
View File
Binary file not shown.
+16 -21
View File
@@ -1,10 +1,10 @@
name: Deploy im-manager (Blue-Green)
name: Deploy im-manager (k3s test cluster)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,留空则使用commit短哈希'
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
jobs:
@@ -17,26 +17,21 @@ jobs:
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 构建镜像(前端+后端一体,自包含多阶段构建)
run: docker build -t im-manager:${{ steps.vars.outputs.tag }} .
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 蓝绿部署
env:
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
REDIS_ADDR: ${{ secrets.REDIS_ADDR }}
REDIS_DB: ${{ secrets.REDIS_DB }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
TOKEN_SECRET_KEY: ${{ secrets.TOKEN_SECRET_KEY }}
IM_ADMIN_API_BASE_URL: ${{ secrets.IM_ADMIN_API_BASE_URL }}
IM_ADMIN_API_SERVICE_ID: ${{ secrets.IM_ADMIN_API_SERVICE_ID }}
IM_ADMIN_API_SECRET: ${{ secrets.IM_ADMIN_API_SECRET }}
IM_INTERNAL_API_BASE_URL: ${{ secrets.IM_INTERNAL_API_BASE_URL }}
IM_INTERNAL_API_SERVICE_NAME: ${{ secrets.IM_INTERNAL_API_SERVICE_NAME }}
IM_INTERNAL_API_SECRET: ${{ secrets.IM_INTERNAL_API_SECRET }}
IM_CONFIG_API_SERVICE_NAME: ${{ secrets.IM_CONFIG_API_SERVICE_NAME }}
IM_CONFIG_API_SECRET: ${{ secrets.IM_CONFIG_API_SECRET }}
- name: 构建并推送镜像(前端+后端一体,自包含多阶段构建)
run: |
IMAGE=im-manager:${{ steps.vars.outputs.tag }} /opt/apps/im-manager/deploy.sh
docker build -t git.118im.com/im_team/im-manager:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-manager:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-manager -n manager-system
kubectl rollout status deployment/im-manager -n manager-system --timeout=120s
+3
View File
@@ -163,3 +163,6 @@ dist
.yarn/install-state.gz
.pnp.*
# Trae local debugging artifacts
.trae/
+27 -14
View File
@@ -21,11 +21,16 @@ COPY server/ .
RUN rm -rf resource/public/admin && mkdir -p resource/public/admin
COPY --from=web-build /web/dist/ ./resource/public/admin/
# main.go does `import _ "hotgo/internal/packed"`, a file normally generated by
# `gf build` (see server/hack/config.yaml: packSrc/packDst) and gitignored —
# generate it explicitly with the same gf CLI version pinned in go.mod.
RUN go install github.com/gogf/gf/cmd/gf/v2@v2.10.0
RUN gf pack resource internal/packed/packed.go
# main.go imports internal/packed. Generate the packed resource file directly
# through GoFrame's library API instead of installing the full gf CLI in CI.
RUN printf '%s\n' \
'package main' \
'import "github.com/gogf/gf/v2/os/gres"' \
'func main() {' \
' if err := gres.PackToGoFileWithOption("resource", "internal/packed/packed.go", "packed", gres.Option{}); err != nil { panic(err) }' \
'}' > /tmp/pack.go \
&& go run /tmp/pack.go \
&& rm /tmp/pack.go
RUN CGO_ENABLED=0 go build -o /out/hotgo main.go
@@ -37,19 +42,27 @@ RUN apk add --no-cache ca-certificates gettext bash tzdata
WORKDIR /app
ENV QUEUE_DRIVER=kafka
ENV KAFKA_ADDRESS=64.118.137.182:9094
ENV KAFKA_ADDRESS=kafka.118im.com:30835
ENV KAFKA_VERSION=2.4.0
ENV KAFKA_USERNAME=kafka-external
ENV KAFKA_PASSWORD=gr58tNnlZOVGW3AmMbL2
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV IM_ADMIN_API_BASE_URL=http://im-server:6060
ENV KAFKA_USERNAME=
ENV KAFKA_PASSWORD=
ENV KAFKA_SASL_MECHANISM=
ENV MYSQL_DSN=
ENV REDIS_ADDR=redis.118im.com:30637
ENV REDIS_DB=2
ENV REDIS_PASSWORD=
ENV TOKEN_SECRET_KEY=hotgo123
ENV IM_ADMIN_API_BASE_URL=https://api.118im.com
ENV IM_ADMIN_API_SERVICE_ID=hotgo-admin
ENV IM_ADMIN_API_SECRET=8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b
ENV IM_INTERNAL_API_BASE_URL=http://im-server:6060
ENV IM_ADMIN_API_SECRET=
ENV IM_INTERNAL_API_BASE_URL=https://api.118im.com
ENV IM_INTERNAL_API_SERVICE_NAME=hotgo-admin
ENV IM_INTERNAL_API_SECRET=8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b
ENV IM_INTERNAL_API_SECRET=
ENV IM_WORKBENCH_API_BASE_URL=http://im-workbench.workbench-system.svc.cluster.local:6061
ENV IM_WORKBENCH_API_SERVICE_NAME=hotgo-admin
ENV IM_WORKBENCH_API_SECRET=hotgo-workbench-internal
ENV IM_CONFIG_API_SERVICE_NAME=im-server
ENV IM_CONFIG_API_SECRET=b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097
ENV IM_CONFIG_API_SECRET=
COPY --from=server-build /src/manifest ./manifest
COPY --from=server-build /src/hack ./hack
@@ -947,6 +947,22 @@ CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
KEY `idx_im_login_failure_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_login_lockouts` (
`id` bigint NOT NULL,
`user_id` bigint DEFAULT NULL,
`account_hash` char(64) NOT NULL,
`failed_attempts` int NOT NULL DEFAULT '0',
`window_started_at` datetime(3) DEFAULT NULL,
`locked_until` datetime(3) DEFAULT NULL,
`last_attempt_at` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_login_lockouts_account` (`account_hash`),
KEY `idx_im_login_lockouts_user` (`user_id`),
KEY `idx_im_login_lockouts_locked` (`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`id` bigint NOT NULL AUTO_INCREMENT,
`enabled` tinyint NOT NULL DEFAULT '1',
@@ -0,0 +1,30 @@
-- 商户级内容审核开关。
-- 可重复执行;新商户默认关闭,现有 default 商户保持当前审核行为。
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'aliyun_moderation_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `aliyun_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用阿里云内容审核'' AFTER `max_group_members`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'local_moderation_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `local_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用本地内容审核'' AFTER `aliyun_moderation_enabled`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
UPDATE `im_tenant_config` c
JOIN `im_tenant` t ON t.`id` = c.`tenant_id`
SET c.`aliyun_moderation_enabled` = 1,
c.`local_moderation_enabled` = 1
WHERE t.`code` = 'default';
@@ -0,0 +1,137 @@
-- 消息加密密钥管理。
-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。
SET NAMES utf8mb4;
SET @now := NOW();
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'message_encryption_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL COMMENT '商户ID',
`key_version` int unsigned NOT NULL COMMENT '密钥版本',
`algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法',
`encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK',
`dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce',
`status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled',
`activated_at` datetime DEFAULT NULL,
`retired_at` datetime DEFAULT NULL,
`remark` varchar(255) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` bigint NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`updated_by` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`),
KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本';
INSERT INTO `hg_sys_dict_type`
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999,
'仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret'
);
INSERT INTO `hg_sys_dict_data`
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret',
'', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `hg_sys_dict_data`
WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01'
);
SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1);
SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1);
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index',
'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index',
1, 'imManager', 0, 0, '', 0, 0, 0, 3,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80,
'商户消息加密开关及密钥版本管理', 1, @now, @now
WHERE @settingsId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex');
SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1);
UPDATE `hg_admin_menu`
SET `pid` = @settingsId,
`title` = '消息密钥管理',
`path` = 'message-encryption/index',
`icon` = 'KeyOutlined',
`permissions` = '/imMessageEncryption/list',
`component` = '/imMessageEncryption/index',
`active_menu` = 'imManager',
`level` = 3,
`tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '),
`sort` = 80,
`hidden` = 0,
`status` = 1,
`updated_at` = @now
WHERE `id` = @keyMenuId;
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '',
'/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize');
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '',
'/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate');
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '',
'/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch');
UPDATE `hg_admin_menu`
SET `level` = 4,
`tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '),
`updated_at` = @now
WHERE `pid` = @keyMenuId AND `type` = 3;
INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`)
SELECT 1, `id` FROM `hg_admin_menu`
WHERE `name` IN (
'imManager',
'imSystemSettings',
'imMessageEncryptionIndex',
'imMessageEncryptionInitialize',
'imMessageEncryptionRotate',
'imMessageEncryptionSwitch'
);
@@ -0,0 +1,33 @@
-- 阿里云文本审核统一复用 hg_sys_config.im_third_party 中的公共凭证、地域和端点。
-- 可重复执行;清理 im_external_config 中历史重复保存的密钥和连接参数。
INSERT INTO `im_external_config`
(`tenant_id`, `service`, `provider`, `endpoint`, `enabled`, `status`, `config_json`, `secret_json`, `created_by`, `updated_by`)
SELECT
t.`id`,
'moderation',
'aliyun_green',
'',
1,
'unconfigured',
JSON_OBJECT(
'nickname_service', 'nickname_detection_pro',
'chat_service', 'chat_detection_pro',
'timeout_ms', 1500,
'failure_mode', 'allow'
),
JSON_OBJECT(),
0,
0
FROM `im_tenant` t
WHERE t.`code` = 'default'
ON DUPLICATE KEY UPDATE
`provider` = VALUES(`provider`),
`endpoint` = VALUES(`endpoint`),
`enabled` = VALUES(`enabled`),
`status` = VALUES(`status`),
`config_json` = VALUES(`config_json`),
`secret_json` = VALUES(`secret_json`),
`checked_at` = NULL,
`check_result` = '',
`updated_at` = CURRENT_TIMESTAMP;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
# IM 登录失败锁定 - 移动端对接文档
> 功能:用户登录密码连续输错达到配置阈值后,账号暂时锁定;锁定期间登录请求被服务端直接拒绝。本文档面向原生移动端(Android / iOS,基于 Tinode SDK)对接者。
> 版本:v1(2026-09-20) | 服务端版本:im_server(已发布至 dev)
---
## 1. 功能交互总览
```
移动端登录(basic 认证,协议不变)
├─ 密码正确 → 计数清零,登录成功(行为与现状完全一致)
├─ 密码错误(未达阈值)→ 401 authentication failed(现状不变)
├─ 密码错误达到阈值 → 429 账号锁定(message 含剩余时间)
└─ 锁定期间再次登录(无论密码对错)→ 429,服务端不校验密码直接拒绝
```
- **登录协议与请求结构零变更**:锁定逻辑全部在服务端(im_server basic 认证链路),移动端只做错误展示适配。
- **阈值/时长由管理后台配置**:【安全管理→安全策略】的登录限频策略,按租户下发(不同租户配置可能不同),客户端不可配置、无需缓存。
- **解锁途径**:① 锁定时间自然到期自动解锁;② 用户联系客服/管理员,在管理后台【日志审计→登录失败日志】手动解锁。两种途径均无需客户端参与。
## 2. 错误响应契约(核心对接点)
### 2.1 响应结构(WebSocket 传输)
登录 `{login}` 消息的 ctrl 响应:
| 场景 | code | text | params |
|---|---|---|---|
| 密码错误,未达阈值 | 401 | `authentication failed` | — |
| 密码错误达到阈值,触发锁定 | **429** | `登录错误次数过多,账号已锁定,请 N 后再试`(N 按剩余时长显示:不足 1 分钟为「1 分钟以内」,不足 1 小时为「1 小时以内」,不足 1 天为「N 小时」,其他为「N 天」) | `{"what":"auth","retry_after_seconds":60}` |
| 锁定期间再次登录 | **429** | 同上(N 随剩余时间递减) | 同上 |
| 登录成功 | 200 | `ok` | 正常 auth 信息 |
实测样例(连错 2 次触发锁定,阈值为 2 的租户):
```json
// 第 1 次错误密码
{"ctrl": {"id": "t1", "code": 401, "text": "authentication failed", "ts": "..."}}
// 第 2 次错误密码(达到阈值)
{"ctrl": {"id": "t1", "code": 429,
"text": "登录错误次数过多,账号已锁定,请 1 分钟以内再试",
"params": {"what": "auth", "retry_after_seconds": 60},
"ts": "..."}}
// 锁定期间(约 12 秒后)正确密码登录,同样被拒绝且剩余时间递减
{"ctrl": {"id": "t1", "code": 429,
"text": "登录错误次数过多,账号已锁定,请 1 分钟以内再试",
"params": {"what": "auth", "retry_after_seconds": 48},
"ts": "..."}}
```
### 2.2 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | int | HTTP 语义状态码。**429 = 账号锁定**,与 401(密码错误)区分 |
| `text` | string | **面向用户展示的完整中文文案,剩余时长自动格式化:不足 1 分钟显示「1 分钟以内」,不足 1 小时显示「1 小时以内」,不足 1 天按小时(向下取整),其他按天(向下取整)**。客户端直接展示即可,无需自行拼接时间 |
| `params.retry_after_seconds` | int | 剩余锁定秒数(向上取整),可用于 UI 倒计时/禁用登录按钮 |
| `params.what` | string | 固定 `auth`,表示错误发生在认证环节 |
### 2.3 gRPC 传输(Android SDK 默认)
gRPC 通道下服务端返回相同的 ctrl 结构(code/text/params 逐字段保留),SDK 会将其封装为带 code 的登录异常抛出。**识别方式不变:code == 429**。
## 3. 客户端处理建议
1. **错误识别**:登录失败回调中判断 `code == 429`(或 SDK 异常携带的 ctrl code)。仅 `429` 按锁定处理,`401`/`403` 维持现有文案与逻辑。
2. **文案展示**:
- 优先展示服务端 `text`(已含剩余分钟数,中文);
- `text` 为空时的兜底文案:`登录错误次数过多,请稍后再试`。
3. **禁止静默重试**:锁定期间重试不会带来额外后果(不刷新锁定时间、不计新失败),但每次都得到 429 提示,建议拿到 429 后**禁用登录按钮或展示倒计时**(用 `retry_after_seconds`),引导用户等待或走找回密码。
4. **倒计时精度**:`retry_after_seconds` 为剩余秒数(比真实剩余时间多算 1 秒余量),客户端倒计时结束即可恢复登录,无需与服务端时钟同步。
5. **多端一致**:Web 端(im_webapp)已按本契约实现(`errors.ts` 的 429 分支),移动端保持相同交互语义。
## 4. 服务端行为细节(影响测试预期)
| 行为 | 说明 |
|---|---|
| 计数对象 | 按**账号名**(username,不区分大小写)哈希计数,与设备/IP 无关:换设备登录同一账号同样累计 |
| 计数窗口 | 滑动窗口 `window_seconds`(默认 30 分钟):窗口内累计达到阈值即锁定;窗口过期后重新计数 |
| 锁定时长 | `lock_seconds`(默认 15 分钟),从触发锁定的那次失败请求起算 |
| 锁定期内正确密码 | 仍返回 429(校验前置于密码比对),**且不刷新锁定时间**(剩余秒数持续递减) |
| 锁定期内请求 | 不写入失败审计日志(避免日志膨胀) |
| 成功登录 | 计数与锁定记录立即清零 |
| 仅 basic 认证 | 锁定只作用于 `basic` scheme(账号密码)登录;token 重连不受影响 |
| 策略关闭 | 租户未配置或 `enabled=false` 时,行为与旧版完全一致(无 429) |
## 5. 自测要点
| 用例 | 预期 |
|---|---|
| 错误密码 × (阈值-1) 次 | 每次 401,文案「账号或密码错误」 |
| 错误密码第 N 次(达阈值) | 429 + 锁定文案 + `retry_after_seconds` ≈ lock_seconds |
| 锁定期内用**正确**密码 | 仍 429,`retry_after_seconds` 递减 |
| 等待锁定到期(或联系管理员解锁) | 正确密码登录成功 200 |
| 登录成功后再连错 (阈值-1) 次 | 均 401(成功登录已清零历史计数) |
> 注:测试环境的阈值/时长以【安全管理→安全策略】当前发布版本为准,联调时可临时调小(如 2 次/60 秒)加速验证。
@@ -0,0 +1,11 @@
-- ----------------------------------------------------------------
-- 登录失败锁定功能:登录失败日志页新增"解锁"按钮权限点
-- 依赖:im_adfahi_batch2_menu.sql(imLoginFailureLogIndex 菜单已存在)
-- ----------------------------------------------------------------
SET @now := NOW();
SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1);
SET @loginFailureId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imLoginFailureLogIndex' LIMIT 1);
INSERT INTO `hg_admin_menu` (`id`,`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
SELECT NULL,@loginFailureId,'解锁登录锁定','imLoginFailureLogUnlock','','','3','','/imLoginFailureLog/unlock','','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@loginFailureId,' '),50,'','1',@now,@now
WHERE @loginFailureId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imLoginFailureLogUnlock');
@@ -0,0 +1,15 @@
-- ----------------------------------------------------------------
-- 登录失败策略独立配置:安全策略菜单下新增"保存登录失败策略"按钮权限点
-- 依赖:im_adfahi_policy_menu.sql(imSecurityPolicyIndex 菜单已存在)
-- 幂等:重复执行不产生重复行
-- ----------------------------------------------------------------
USE hotgo;
SET @now := NOW();
SET @securityPolicyId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyPid := (SELECT `pid` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyLevel := (SELECT `level` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
SET @policyTree := (SELECT `tree` FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyIndex' LIMIT 1);
INSERT INTO `hg_admin_menu` (`id`,`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
SELECT NULL,@securityPolicyId,'保存登录失败策略','imSecurityPolicyLoginLockoutConfig','','','3','','/imSecurityPolicy/loginLockoutConfig','','','1','','0','0','','0','0','0',IFNULL(@policyLevel,2)+1,CONCAT(IFNULL(@policyTree,''),'tr_',@securityPolicyId,' '),80,'','1',@now,@now
WHERE @securityPolicyId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imSecurityPolicyLoginLockoutConfig');
+19
View File
@@ -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` = '客服邮箱');
@@ -0,0 +1,69 @@
package cultivatecontent
import (
"github.com/gogf/gf/v2/frame/g"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
)
type ListReq struct {
g.Meta `path:"/cultivate/content/list" method:"get" tags:"养成管理" summary:"获取动态或评论审核列表"`
sysin.CultivateContentListInp
}
type ListRes struct {
form.PageRes
List []*sysin.CultivateContentListModel `json:"list"`
}
type ReviewReq struct {
g.Meta `path:"/cultivate/content/review" method:"post" tags:"养成管理" summary:"审核或下架动态评论"`
sysin.CultivateContentReviewInp
}
type ReviewRes struct{}
type BatchReviewReq struct {
g.Meta `path:"/cultivate/content/batch-review" method:"post" tags:"养成管理" summary:"批量审核或下架动态评论"`
sysin.CultivateContentBatchReviewInp
}
type BatchReviewRes struct {
*sysin.CultivateContentBatchReviewModel
}
type ViewReq struct {
g.Meta `path:"/cultivate/content/view" method:"get" tags:"养成管理" summary:"获取内容审核详情"`
sysin.CultivateContentViewInp
}
type ViewRes struct {
*sysin.CultivateContentViewModel
}
type ReportListReq struct {
g.Meta `path:"/cultivate/reports/list" method:"get" tags:"养成管理" summary:"获取举报列表"`
sysin.CultivateReportListInp
}
type ReportListRes struct {
form.PageRes
List []*sysin.CultivateReportListModel `json:"list"`
}
type ReportHandleReq struct {
g.Meta `path:"/cultivate/reports/handle" method:"post" tags:"养成管理" summary:"处理举报"`
sysin.CultivateReportHandleInp
}
type ReportHandleRes struct{}
type ReportViewReq struct {
g.Meta `path:"/cultivate/reports/view" method:"get" tags:"养成管理" summary:"获取举报详情"`
sysin.CultivateReportViewInp
}
type ReportViewRes struct {
*sysin.CultivateReportViewModel
}
type LogListReq struct {
g.Meta `path:"/cultivate/logs/list" method:"get" tags:"养成管理" summary:"获取养成管理操作日志"`
sysin.CultivateOperationLogListInp
}
type LogListRes struct {
form.PageRes
List []*sysin.CultivateOperationLogListModel `json:"list"`
}
type DashboardReq struct {
g.Meta `path:"/cultivate/dashboard" method:"get" tags:"养成管理" summary:"获取养成管理统计"`
sysin.CultivateDashboardInp
}
type DashboardRes struct{ *sysin.CultivateDashboardModel }
@@ -0,0 +1,33 @@
package cultivateflower
import (
"github.com/gogf/gf/v2/frame/g"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
)
type ListReq struct {
g.Meta `path:"/cultivate/flowers/list" method:"get" tags:"养成管理" summary:"获取花种列表"`
sysin.CultivateFlowerListInp
}
type ListRes struct {
form.PageRes
List []*sysin.CultivateFlowerListModel `json:"list"`
}
type ViewReq struct {
g.Meta `path:"/cultivate/flowers/view" method:"get" tags:"养成管理" summary:"获取花种详情"`
sysin.CultivateFlowerViewInp
}
type ViewRes struct {
*sysin.CultivateFlowerViewModel
}
type EditReq struct {
g.Meta `path:"/cultivate/flowers/edit" method:"post" tags:"养成管理" summary:"新建或编辑花种及阶段规则"`
sysin.CultivateFlowerEditInp
}
type EditRes struct{}
type StatusReq struct {
g.Meta `path:"/cultivate/flowers/status" method:"post" tags:"养成管理" summary:"更新花种上下架状态"`
sysin.CultivateFlowerStatusInp
}
type StatusRes struct{}
+155 -1
View File
@@ -1,6 +1,10 @@
package imconfiginternal
import "github.com/gogf/gf/v2/frame/g"
import (
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type TenantReq struct {
g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"`
@@ -58,6 +62,156 @@ 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:"获取内容审核服务配置"`
}
type ModerationProviderRes struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
RegionID string `json:"region_id"`
NicknameService string `json:"nickname_service"`
ChatService string `json:"chat_service"`
TimeoutMS int `json:"timeout_ms"`
FailureMode string `json:"failure_mode"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
}
type ModerationPolicyReq struct {
g.Meta `path:"/internal/im-config/tenants/{code}/moderation-policy" method:"get" tags:"IM内部配置" summary:"获取商户内容审核策略"`
Code string `json:"code" in:"path" dc:"商户编码"`
}
type ModerationPolicyRes struct {
TenantID int64 `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
PolicyVersion uint `json:"policy_version"`
Enabled bool `json:"enabled"`
AliyunEnabled bool `json:"aliyun_enabled"`
LocalWordsEnabled bool `json:"local_words_enabled"`
Scenes map[string]bool `json:"scenes"`
RiskActions map[string]string `json:"risk_actions"`
FailureMode string `json:"failure_mode"`
BlockMessage string `json:"block_message"`
}
type SecurityPolicyReq struct {
g.Meta `path:"/internal/im-config/tenants/{code}/security-policy" method:"get" tags:"IM内部配置" summary:"获取商户安全策略"`
Code string `json:"code" in:"path" dc:"商户编码"`
}
type SecurityPolicyRes struct {
TenantID int64 `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
PolicyVersion uint `json:"policy_version"`
GlobalFriendEnabled bool `json:"global_friend_enabled"`
FriendDisabledPolicy map[string]interface{} `json:"friend_disabled_policy"`
MessageCurfewEnabled bool `json:"message_curfew_enabled"`
LoginRateLimit *SecurityLoginRateLimitRes `json:"login_rate_limit"`
CurfewRules []*SecurityCurfewRuleRes `json:"curfew_rules"`
}
type RealnamePolicyReq struct {
g.Meta `path:"/internal/im-config/tenants/by-id/{id}/realname-policy" method:"get" tags:"IM内部配置" summary:"获取租户实名认证策略"`
Id int64 `json:"id" in:"path" dc:"租户ID"`
}
type RealnamePolicyRes struct {
TenantID int64 `json:"tenant_id"`
RealnameReviewPolicy int `json:"realname_review_policy"`
}
type SecurityLoginRateLimitRes struct {
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"password_max_attempts"`
LockSeconds int `json:"lock_seconds"`
WindowSeconds int `json:"window_seconds"`
}
type SecurityCurfewRuleRes struct {
RuleCode string `json:"rule_code"`
RuleName string `json:"rule_name"`
DateType string `json:"date_type"`
CustomDates []string `json:"custom_dates"`
Weekdays []int `json:"weekdays"`
Timezone string `json:"timezone"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Scope string `json:"scope"`
WhitelistUserIds []string `json:"whitelist_user_ids"`
WhitelistGroupIds []string `json:"whitelist_group_ids"`
Message string `json:"message"`
Status int `json:"status"`
Sort int `json:"sort"`
}
type SensitiveWordsReq struct {
g.Meta `path:"/internal/im-config/tenants/{code}/sensitive-words" method:"get" tags:"IM内部配置" summary:"获取商户有效敏感词"`
Code string `json:"code" in:"path" dc:"商户编码"`
}
type SensitiveWordsRes struct {
TenantID int64 `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
Version int64 `json:"version"`
Items []*SensitiveWordModel `json:"items"`
}
type MessageKeyActiveReq struct {
g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/active" method:"get" tags:"IM内部配置" summary:"获取商户当前消息密钥"`
TenantCode string `json:"tenantCode" in:"path"`
}
type MessageKeyActiveRes struct {
*sysin.ImMessageKeyModel
}
type MessageKeyVersionReq struct {
g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/{version}" method:"get" tags:"IM内部配置" summary:"获取商户指定版本消息密钥"`
TenantCode string `json:"tenantCode" in:"path"`
Version uint `json:"version" in:"path"`
}
type MessageKeyVersionRes struct {
*sysin.ImMessageKeyModel
}
type MessageKeyAckReq struct {
g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/ack" method:"post" tags:"IM内部配置" summary:"确认商户消息密钥可用"`
TenantCode string `json:"tenantCode" in:"path"`
Version uint `json:"version" v:"required|min:1#密钥版本不能为空|密钥版本不正确"`
}
type MessageKeyAckRes struct{}
type SensitiveWordModel struct {
ID int64 `json:"id"`
Word string `json:"word"`
MatchType string `json:"match_type"`
Scenes []string `json:"scenes"`
Action string `json:"action"`
Replacement string `json:"replacement"`
RiskLevel string `json:"risk_level"`
Priority int `json:"priority"`
}
type TenantLoginIPWhitelistReq struct {
g.Meta `path:"/internal/im-config/tenants/by-id/{id}/login-ip-whitelist" method:"get" tags:"IM内部配置" summary:"获取租户渠道登录IP白名单"`
Id int64 `json:"id" in:"path" dc:"租户ID"`
-80
View File
@@ -263,83 +263,3 @@ type AdminLogListRes struct {
form.PageRes
List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"`
}
// ProfileUpdateReq 通过Kafka修改IM群资料
type ProfileUpdateReq struct {
g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群资料"`
sysin.ImGroupProfileUpdateInp
}
type ProfileUpdateRes struct {
*sysin.ImGroupCommandModel
}
// GovernanceUpdateReq 通过Kafka修改IM群治理配置
type GovernanceUpdateReq struct {
g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群治理配置"`
sysin.ImGroupGovernanceUpdateInp
}
type GovernanceUpdateRes struct {
*sysin.ImGroupCommandModel
}
// AdminSetReq 通过Kafka设置IM群管理员
type AdminSetReq struct {
g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"Kafka设置IM群管理员"`
sysin.ImGroupAdminSetInp
}
type AdminSetRes struct {
*sysin.ImGroupCommandModel
}
// AdminRemoveReq 通过Kafka删除IM群管理员
type AdminRemoveReq struct {
g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"Kafka删除IM群管理员"`
sysin.ImGroupAdminRemoveInp
}
type AdminRemoveRes struct {
*sysin.ImGroupCommandModel
}
// MemberMuteReq 通过Kafka禁言IM群成员
type MemberMuteReq struct {
g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"`
sysin.ImGroupMemberMuteInp
}
type MemberMuteRes struct {
*sysin.ImGroupCommandModel
}
// MemberUnmuteReq 通过Kafka解除IM群成员禁言
type MemberUnmuteReq struct {
g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"`
sysin.ImGroupMemberUnmuteInp
}
type MemberUnmuteRes struct {
*sysin.ImGroupCommandModel
}
// MemberRemoveReq 通过Kafka移出IM群成员
type MemberRemoveReq struct {
g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"Kafka移出IM群成员"`
sysin.ImGroupMemberRemoveInp
}
type MemberRemoveRes struct {
*sysin.ImGroupCommandModel
}
// OwnerTransferReq 通过Kafka转让IM群主
type OwnerTransferReq struct {
g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"Kafka转让IM群主"`
sysin.ImGroupOwnerTransferInp
}
type OwnerTransferRes struct {
*sysin.ImGroupCommandModel
}
@@ -35,3 +35,8 @@ type BanUserReq struct {
sysin.ImLoginFailureBanUserInp
}
type BanUserRes struct{ *sysin.ImUserCommandModel }
type UnlockReq struct {
g.Meta `path:"/imLoginFailureLog/unlock" method:"post" tags:"IM登录失败日志" summary:"解锁登录失败锁定"`
sysin.ImLoginFailureUnlockInp
}
type UnlockRes struct{}
@@ -0,0 +1,36 @@
package immessageencryption
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/imMessageEncryption/list" method:"get" tags:"消息加密密钥" summary:"获取商户消息加密状态"`
sysin.ImMessageEncryptionListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImMessageEncryptionListModel `json:"list" dc:"数据列表"`
}
type InitializeReq struct {
g.Meta `path:"/imMessageEncryption/initialize" method:"post" tags:"消息加密密钥" summary:"初始化商户消息密钥"`
sysin.ImMessageEncryptionActionInp
}
type InitializeRes struct{}
type RotateReq struct {
g.Meta `path:"/imMessageEncryption/rotate" method:"post" tags:"消息加密密钥" summary:"轮换商户消息密钥"`
sysin.ImMessageEncryptionActionInp
}
type RotateRes struct{}
type SwitchReq struct {
g.Meta `path:"/imMessageEncryption/switch" method:"post" tags:"消息加密密钥" summary:"切换商户消息加密"`
sysin.ImMessageEncryptionSwitchInp
}
type SwitchRes struct{}
@@ -39,6 +39,16 @@ type RestoreDefaultReq struct {
type RestoreDefaultRes struct {
Id int64 `json:"id"`
}
type LoginLockoutConfigGetReq struct {
g.Meta `path:"/imSecurityPolicy/loginLockoutConfig" method:"get" tags:"IM安全策略" summary:"获取登录失败策略配置"`
sysin.ImLoginLockoutConfigGetInp
}
type LoginLockoutConfigGetRes struct{ *sysin.ImLoginLockoutConfigModel }
type LoginLockoutConfigSaveReq struct {
g.Meta `path:"/imSecurityPolicy/loginLockoutConfig" method:"post" tags:"IM安全策略" summary:"保存登录失败策略配置"`
sysin.ImLoginLockoutConfigSaveInp
}
type LoginLockoutConfigSaveRes struct{}
type IPRuleListReq struct {
g.Meta `path:"/imSecurityPolicy/ipRuleList" method:"get" tags:"IM安全策略" summary:"获取IP黑白名单"`
sysin.ImSecurityIPRuleListInp
+10 -2
View File
@@ -33,9 +33,17 @@ type ViewRes struct {
*sysin.ImTenantViewModel
}
// EditReq 修改/新增IM租户
// AddReq 新增IM租户
type AddReq struct {
g.Meta `path:"/imTenant/add" method:"post" tags:"IM租户" summary:"新增IM租户"`
sysin.ImTenantEditInp
}
type AddRes struct{}
// EditReq 修改IM租户
type EditReq struct {
g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改/新增IM租户"`
g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改IM租户"`
sysin.ImTenantEditInp
}
+7
View File
@@ -99,6 +99,13 @@ type AdjustGroupReq struct {
type AdjustGroupRes struct{}
type FriendAddReq struct {
g.Meta `path:"/imUser/friendAdd" method:"post" tags:"IM用户" summary:"设置IM用户加好友开关"`
sysin.ImUserFriendAddInp
}
type FriendAddRes struct{}
type IPWhitelistReq struct {
g.Meta `path:"/imUser/ipWhitelist" method:"get" tags:"IM用户" summary:"获取IM用户IP白名单"`
sysin.ImUserIPWhitelistInp
+4 -3
View File
@@ -4,10 +4,11 @@ go 1.24.4
require (
github.com/IBM/sarama v1.45.2
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6
github.com/alibabacloud-go/tea v1.3.9
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/apache/rocketmq-client-go/v2 v2.1.2
github.com/casbin/casbin/v2 v2.108.0
+8 -7
View File
@@ -23,8 +23,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.2/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7 h1:ASXSBga98QrGMxbIThCD6jAti09gedLfvry6yJtsoBE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7/go.mod h1:TBpgqm3XofZz2LCYjZhektGPU7ArEgascyzbm4SjFo4=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
@@ -37,6 +37,8 @@ github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6 h1:UTl97mt2qfavxveqCkaVg4
github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6/go.mod h1:UWpcGrWwTbES9QW7OQ7xDffukMJ/l7lzioixIz8+lgY=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
@@ -48,16 +50,15 @@ github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy
github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.8/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.3.9 h1:bjgt1bvdY780vz/17iWNNtbXl4A77HWntWMeaUF3So0=
github.com/alibabacloud-go/tea v1.3.9/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
+10
View File
@@ -34,6 +34,16 @@ gfcli:
gJsonSupport: true
clear: false
- link: "mysql:root:root@tcp(127.0.0.1:3306)/cultivate?loc=Local&parseTime=true&charset=utf8mb4"
group: "cultivate"
tables: "cv_flower_catalog,cv_flower_stage_rule,cv_feed_post,cv_feed_media,cv_feed_comment,cv_feed_report,cv_content_audit_log,cv_admin_operation_log"
removePrefix: "cv_"
descriptionTag: true
noModelComment: true
jsonCase: "CamelLower"
gJsonSupport: true
clear: false
# service: # 生成业务配置
# srcFolder: "internal/logic"
+54
View File
@@ -0,0 +1,54 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
const (
CultivateFlowerOnline = "online"
CultivateFlowerOffline = "offline"
CultivateFlowerActivityOnly = "activity_only"
CultivateFlowerMaintenance = "maintenance"
)
var CultivateFlowerStatusOptions = []*model.Option{
dict.GenSuccessOption(CultivateFlowerOnline, "已上架"),
dict.GenDefaultOption(CultivateFlowerOffline, "已下架"),
dict.GenPrimaryOption(CultivateFlowerActivityOnly, "仅活动"),
dict.GenWarningOption(CultivateFlowerMaintenance, "维护中"),
}
var CultivateContentStatusOptions = []*model.Option{
dict.GenWarningOption("reviewing", "待审核"),
dict.GenSuccessOption("published", "已发布"),
dict.GenErrorOption("rejected", "已拒绝"),
dict.GenDefaultOption("taken_down", "已下架"),
dict.GenDefaultOption("deleted", "已删除"),
}
var CultivateReportStatusOptions = []*model.Option{
dict.GenWarningOption("submitted", "待处理"),
dict.GenPrimaryOption("processing", "处理中"),
dict.GenSuccessOption("resolved", "已处理"),
dict.GenDefaultOption("rejected", "已驳回"),
}
var CultivateContentTypeOptions = []*model.Option{
dict.GenPrimaryOption("feed_post", "动态"),
dict.GenInfoOption("feed_comment", "评论"),
}
var CultivateReportObjectTypeOptions = []*model.Option{
dict.GenPrimaryOption("feed_post", "动态"),
dict.GenInfoOption("feed_comment", "评论"),
dict.GenDefaultOption("user", "用户"),
}
func init() {
dict.RegisterEnums("CultivateFlowerStatusOptions", "养成花种状态", CultivateFlowerStatusOptions)
dict.RegisterEnums("CultivateContentStatusOptions", "养成内容状态", CultivateContentStatusOptions)
dict.RegisterEnums("CultivateReportStatusOptions", "养成举报状态", CultivateReportStatusOptions)
dict.RegisterEnums("CultivateContentTypeOptions", "养成内容类型", CultivateContentTypeOptions)
dict.RegisterEnums("CultivateReportObjectTypeOptions", "养成举报对象类型", CultivateReportObjectTypeOptions)
}
+1
View File
@@ -13,6 +13,7 @@ const (
RegionSpilt = " / " // 地区分隔符
Unknown = "Unknown" // Unknown
SuperRoleKey = "super" // 超管角色唯一标识符,通过角色验证超管
ImPlatformRoleKey = "supreme_manager" // IM平台超管角色唯一标识符
MaxServeLogContentLen = 2048 // 最大保留服务日志内容大小
SysDefaultLanguage = "zh_CN" // 系统默认语言,当配置文件没有国际化配置时生效
)
@@ -0,0 +1,12 @@
package consts
const (
ImInternalSecretDictType = "im_internal_secret"
ImMessageRootKEKLabel = "core_material_01"
ImMessageKeyAlgorithm = "AES-256-GCM"
ImMessageKeyStatusPending = "pending"
ImMessageKeyStatusActive = "active"
ImMessageKeyStatusRetired = "retired"
ImMessageKeyStatusDisabled = "disabled"
)
@@ -0,0 +1,86 @@
package sys
import (
"context"
"hotgo/api/admin/cultivatecontent"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var CultivateContent = cCultivateContent{}
type cCultivateContent struct{}
func (*cCultivateContent) Dashboard(ctx context.Context, req *cultivatecontent.DashboardReq) (res *cultivatecontent.DashboardRes, err error) {
data, err := service.SysCultivateContent().Dashboard(ctx, &req.CultivateDashboardInp)
if err != nil {
return nil, err
}
return &cultivatecontent.DashboardRes{CultivateDashboardModel: data}, nil
}
func (*cCultivateContent) List(ctx context.Context, req *cultivatecontent.ListReq) (res *cultivatecontent.ListRes, err error) {
list, total, err := service.SysCultivateContent().List(ctx, &req.CultivateContentListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.CultivateContentListModel{}
}
res = &cultivatecontent.ListRes{List: list}
res.PageRes.Pack(req, total)
return
}
func (*cCultivateContent) View(ctx context.Context, req *cultivatecontent.ViewReq) (res *cultivatecontent.ViewRes, err error) {
data, err := service.SysCultivateContent().View(ctx, &req.CultivateContentViewInp)
if err != nil {
return nil, err
}
return &cultivatecontent.ViewRes{CultivateContentViewModel: data}, nil
}
func (*cCultivateContent) Review(ctx context.Context, req *cultivatecontent.ReviewReq) (res *cultivatecontent.ReviewRes, err error) {
err = service.SysCultivateContent().Review(ctx, &req.CultivateContentReviewInp)
return
}
func (*cCultivateContent) BatchReview(ctx context.Context, req *cultivatecontent.BatchReviewReq) (res *cultivatecontent.BatchReviewRes, err error) {
data, err := service.SysCultivateContent().BatchReview(ctx, &req.CultivateContentBatchReviewInp)
if err != nil {
return nil, err
}
return &cultivatecontent.BatchReviewRes{CultivateContentBatchReviewModel: data}, nil
}
func (*cCultivateContent) ReportList(ctx context.Context, req *cultivatecontent.ReportListReq) (res *cultivatecontent.ReportListRes, err error) {
list, total, err := service.SysCultivateContent().ReportList(ctx, &req.CultivateReportListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.CultivateReportListModel{}
}
res = &cultivatecontent.ReportListRes{List: list}
res.PageRes.Pack(req, total)
return
}
func (*cCultivateContent) ReportHandle(ctx context.Context, req *cultivatecontent.ReportHandleReq) (res *cultivatecontent.ReportHandleRes, err error) {
err = service.SysCultivateContent().ReportHandle(ctx, &req.CultivateReportHandleInp)
return
}
func (*cCultivateContent) ReportView(ctx context.Context, req *cultivatecontent.ReportViewReq) (res *cultivatecontent.ReportViewRes, err error) {
data, err := service.SysCultivateContent().ReportView(ctx, &req.CultivateReportViewInp)
if err != nil {
return nil, err
}
return &cultivatecontent.ReportViewRes{CultivateReportViewModel: data}, nil
}
func (*cCultivateContent) LogList(ctx context.Context, req *cultivatecontent.LogListReq) (res *cultivatecontent.LogListRes, err error) {
list, total, err := service.SysCultivateContent().LogList(ctx, &req.CultivateOperationLogListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.CultivateOperationLogListModel{}
}
res = &cultivatecontent.LogListRes{List: list}
res.PageRes.Pack(req, total)
return
}
@@ -0,0 +1,40 @@
package sys
import (
"context"
"hotgo/api/admin/cultivateflower"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var CultivateFlower = cCultivateFlower{}
type cCultivateFlower struct{}
func (*cCultivateFlower) List(ctx context.Context, req *cultivateflower.ListReq) (res *cultivateflower.ListRes, err error) {
list, total, err := service.SysCultivateFlower().List(ctx, &req.CultivateFlowerListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.CultivateFlowerListModel{}
}
res = &cultivateflower.ListRes{List: list}
res.PageRes.Pack(req, total)
return
}
func (*cCultivateFlower) View(ctx context.Context, req *cultivateflower.ViewReq) (res *cultivateflower.ViewRes, err error) {
data, err := service.SysCultivateFlower().View(ctx, &req.CultivateFlowerViewInp)
if err != nil {
return nil, err
}
return &cultivateflower.ViewRes{CultivateFlowerViewModel: data}, nil
}
func (*cCultivateFlower) Edit(ctx context.Context, req *cultivateflower.EditReq) (res *cultivateflower.EditRes, err error) {
err = service.SysCultivateFlower().Edit(ctx, &req.CultivateFlowerEditInp)
return
}
func (*cCultivateFlower) Status(ctx context.Context, req *cultivateflower.StatusReq) (res *cultivateflower.StatusRes, err error) {
err = service.SysCultivateFlower().Status(ctx, &req.CultivateFlowerStatusInp)
return
}
@@ -314,91 +314,3 @@ func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListRe
res.PageRes.Pack(req, totalCount)
return
}
// ProfileUpdate 通过Kafka修改IM群资料
func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdateReq) (res *imgroup.ProfileUpdateRes, err error) {
data, err := service.SysImGroup().ProfileUpdate(ctx, &req.ImGroupProfileUpdateInp)
if err != nil {
return
}
res = new(imgroup.ProfileUpdateRes)
res.ImGroupCommandModel = data
return
}
// GovernanceUpdate 通过Kafka修改IM群治理配置
func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) {
data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp)
if err != nil {
return
}
res = new(imgroup.GovernanceUpdateRes)
res.ImGroupCommandModel = data
return
}
// AdminSet 通过Kafka设置IM群管理员
func (c *cImGroup) AdminSet(ctx context.Context, req *imgroup.AdminSetReq) (res *imgroup.AdminSetRes, err error) {
data, err := service.SysImGroup().AdminSet(ctx, &req.ImGroupAdminSetInp)
if err != nil {
return
}
res = new(imgroup.AdminSetRes)
res.ImGroupCommandModel = data
return
}
// AdminRemove 通过Kafka删除IM群管理员
func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) (res *imgroup.AdminRemoveRes, err error) {
data, err := service.SysImGroup().AdminRemove(ctx, &req.ImGroupAdminRemoveInp)
if err != nil {
return
}
res = new(imgroup.AdminRemoveRes)
res.ImGroupCommandModel = data
return
}
// MemberMute 通过Kafka禁言IM群成员
func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) (res *imgroup.MemberMuteRes, err error) {
data, err := service.SysImGroup().MemberMute(ctx, &req.ImGroupMemberMuteInp)
if err != nil {
return
}
res = new(imgroup.MemberMuteRes)
res.ImGroupCommandModel = data
return
}
// MemberUnmute 通过Kafka解除IM群成员禁言
func (c *cImGroup) MemberUnmute(ctx context.Context, req *imgroup.MemberUnmuteReq) (res *imgroup.MemberUnmuteRes, err error) {
data, err := service.SysImGroup().MemberUnmute(ctx, &req.ImGroupMemberUnmuteInp)
if err != nil {
return
}
res = new(imgroup.MemberUnmuteRes)
res.ImGroupCommandModel = data
return
}
// MemberRemove 通过Kafka移出IM群成员
func (c *cImGroup) MemberRemove(ctx context.Context, req *imgroup.MemberRemoveReq) (res *imgroup.MemberRemoveRes, err error) {
data, err := service.SysImGroup().MemberRemove(ctx, &req.ImGroupMemberRemoveInp)
if err != nil {
return
}
res = new(imgroup.MemberRemoveRes)
res.ImGroupCommandModel = data
return
}
// OwnerTransfer 通过Kafka转让IM群主
func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) {
data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp)
if err != nil {
return
}
res = new(imgroup.OwnerTransferRes)
res.ImGroupCommandModel = data
return
}
@@ -37,3 +37,6 @@ func (c *cImLoginFailureLog) BanUser(ctx context.Context, req *imloginfailurelog
data, err := service.SysImLoginFailureLog().BanUser(ctx, &req.ImLoginFailureBanUserInp)
return &imloginfailurelog.BanUserRes{ImUserCommandModel: data}, err
}
func (c *cImLoginFailureLog) Unlock(ctx context.Context, req *imloginfailurelog.UnlockReq) (*imloginfailurelog.UnlockRes, error) {
return &imloginfailurelog.UnlockRes{}, service.SysImLoginFailureLog().Unlock(ctx, &req.ImLoginFailureUnlockInp)
}
@@ -0,0 +1,41 @@
package sys
import (
"context"
"hotgo/api/admin/immessageencryption"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImMessageEncryption = cImMessageEncryption{}
type cImMessageEncryption struct{}
func (c *cImMessageEncryption) List(ctx context.Context, req *immessageencryption.ListReq) (res *immessageencryption.ListRes, err error) {
list, total, err := service.SysImMessageEncryption().List(ctx, &req.ImMessageEncryptionListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.ImMessageEncryptionListModel{}
}
res = new(immessageencryption.ListRes)
res.List = list
res.PageRes.Pack(req, total)
return res, nil
}
func (c *cImMessageEncryption) Initialize(ctx context.Context, req *immessageencryption.InitializeReq) (res *immessageencryption.InitializeRes, err error) {
err = service.SysImMessageEncryption().Initialize(ctx, &req.ImMessageEncryptionActionInp)
return
}
func (c *cImMessageEncryption) Rotate(ctx context.Context, req *immessageencryption.RotateReq) (res *immessageencryption.RotateRes, err error) {
err = service.SysImMessageEncryption().Rotate(ctx, &req.ImMessageEncryptionActionInp)
return
}
func (c *cImMessageEncryption) Switch(ctx context.Context, req *immessageencryption.SwitchReq) (res *immessageencryption.SwitchRes, err error) {
err = service.SysImMessageEncryption().Switch(ctx, &req.ImMessageEncryptionSwitchInp)
return
}
@@ -49,6 +49,17 @@ func (c *cImSecurityPolicy) RestoreDefault(ctx context.Context, req *imsecurityp
}
return &imsecuritypolicy.RestoreDefaultRes{Id: id}, nil
}
func (c *cImSecurityPolicy) GetLoginLockoutConfig(ctx context.Context, req *imsecuritypolicy.LoginLockoutConfigGetReq) (res *imsecuritypolicy.LoginLockoutConfigGetRes, err error) {
data, err := service.SysImSecurityPolicy().GetLoginLockoutConfig(ctx, &req.ImLoginLockoutConfigGetInp)
if err != nil {
return nil, err
}
return &imsecuritypolicy.LoginLockoutConfigGetRes{ImLoginLockoutConfigModel: data}, nil
}
func (c *cImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, req *imsecuritypolicy.LoginLockoutConfigSaveReq) (res *imsecuritypolicy.LoginLockoutConfigSaveRes, err error) {
err = service.SysImSecurityPolicy().SaveLoginLockoutConfig(ctx, &req.ImLoginLockoutConfigSaveInp)
return
}
func (c *cImSecurityPolicy) IPRuleList(ctx context.Context, req *imsecuritypolicy.IPRuleListReq) (res *imsecuritypolicy.IPRuleListRes, err error) {
list, total, err := service.SysImSecurityPolicy().IPRuleList(ctx, &req.ImSecurityIPRuleListInp)
if err != nil {
@@ -35,6 +35,12 @@ func (c *cImTenant) List(ctx context.Context, req *imtenant.ListReq) (res *imten
return
}
// Add 新增IM租户
func (c *cImTenant) Add(ctx context.Context, req *imtenant.AddReq) (res *imtenant.AddRes, err error) {
err = service.SysImTenant().Add(ctx, &req.ImTenantEditInp)
return
}
// Edit 更新IM租户
func (c *cImTenant) Edit(ctx context.Context, req *imtenant.EditReq) (res *imtenant.EditRes, err error) {
err = service.SysImTenant().Edit(ctx, &req.ImTenantEditInp)
@@ -108,6 +108,11 @@ func (c *cImUser) AdjustGroup(ctx context.Context, req *imuser.AdjustGroupReq) (
return
}
func (c *cImUser) FriendAdd(ctx context.Context, req *imuser.FriendAddReq) (res *imuser.FriendAddRes, err error) {
err = service.SysImUser().FriendAdd(ctx, &req.ImUserFriendAddInp)
return
}
func (c *cImUser) IPWhitelist(ctx context.Context, req *imuser.IPWhitelistReq) (res *imuser.IPWhitelistRes, err error) {
data, err := service.SysImUser().IPWhitelist(ctx, &req.ImUserIPWhitelistInp)
if err != nil {
+1 -1
View File
@@ -30,7 +30,7 @@ func Serve(r *ghttp.Request) {
checks := map[string]string{}
statusCode := http.StatusOK
if err := g.DB().Ctx(ctx).Raw("SELECT 1").Scan(new(int)); err != nil {
if _, err := g.DB().Ctx(ctx).Raw("SELECT 1").One(); err != nil {
checks["mysql"] = err.Error()
statusCode = http.StatusServiceUnavailable
} else {
@@ -2,10 +2,14 @@ package imconfig
import (
"context"
"encoding/json"
api "hotgo/api/admin/imconfiginternal"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/immoderation"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
@@ -14,6 +18,18 @@ import (
"github.com/gogf/gf/v2/util/gconv"
)
const defaultModerationTenantCode = "default"
var defaultModerationScenes = map[string]bool{
"user_nickname": true, "user_signature": true, "group_name": true,
"group_nickname": true, "private_message": true, "group_message": true,
"group_announcement": true,
}
var defaultModerationRiskActions = map[string]string{
"high": "block", "medium": "block", "low": "audit_only", "none": "allow",
}
var Config = cConfig{}
type cConfig struct{}
@@ -91,6 +107,375 @@ 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 {
return nil, err
}
var row struct {
Provider string `json:"provider"`
Enabled int `json:"enabled"`
ConfigJSON string `json:"config_json"`
}
record, err := imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Fields("provider", "enabled", "CAST(config_json AS CHAR) config_json").
Where("tenant_id", tenant.Id).Where("service", consts.ImExternalServiceModeration).One()
if err != nil {
return nil, gerror.Wrap(err, "获取内容审核服务配置失败")
}
if !record.IsEmpty() {
if err = record.Struct(&row); err != nil {
return nil, gerror.Wrap(err, "解析内容审核服务配置失败")
}
}
globalConfig, err := immoderation.LoadAliyunGlobalConfig(ctx)
if err != nil {
return nil, err
}
config := map[string]any{}
_ = json.Unmarshal([]byte(row.ConfigJSON), &config)
res = &api.ModerationProviderRes{
Provider: stringValue(map[string]any{"provider": row.Provider}, "provider", "aliyun_green"),
Endpoint: globalConfig.Endpoint, Enabled: row.Enabled == 1,
RegionID: globalConfig.RegionID,
NicknameService: stringValue(config, "nickname_service", "nickname_detection_pro"),
ChatService: stringValue(config, "chat_service", "chat_detection_pro"),
TimeoutMS: intValue(config, "timeout_ms", 1500),
FailureMode: moderationFailureMode(stringValue(config, "failure_mode", "allow")),
AccessKeyID: globalConfig.AccessKeyID,
AccessKeySecret: globalConfig.AccessKeySecret,
}
return res, nil
}
func (c *cConfig) ModerationPolicy(ctx context.Context, req *api.ModerationPolicyReq) (res *api.ModerationPolicyRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code))
if err != nil {
return nil, err
}
res = defaultModerationPolicy(tenant)
var tenantConfig struct {
AliyunModerationEnabled int `json:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled"`
}
if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields("aliyun_moderation_enabled", "local_moderation_enabled").
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&tenantConfig); err != nil {
return nil, gerror.Wrap(err, "获取商户内容审核开关失败")
}
res.AliyunEnabled = tenantConfig.AliyunModerationEnabled == 1
res.LocalWordsEnabled = tenantConfig.LocalModerationEnabled == 1
res.Enabled = res.AliyunEnabled || res.LocalWordsEnabled
columns := dao.ImSecurityPolicy.Columns()
var row struct {
PolicyVersion uint `json:"policy_version"`
SensitiveWordPolicy string `json:"sensitive_word_policy"`
}
record, err := imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(columns.PolicyVersion, "CAST("+columns.SensitiveWordPolicy+" AS CHAR) sensitive_word_policy").
Where(columns.TenantId, tenant.Id).Where(columns.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(columns.DeletedAt).OrderDesc(columns.PolicyVersion).One()
if err != nil {
return nil, gerror.Wrap(err, "获取商户内容审核策略失败")
}
if record.IsEmpty() {
return res, nil
}
if err = record.Struct(&row); err != nil {
return nil, gerror.Wrap(err, "解析商户内容审核策略失败")
}
if row.PolicyVersion == 0 || strings.TrimSpace(row.SensitiveWordPolicy) == "" {
return res, nil
}
var policy struct {
Scenes map[string]bool `json:"scenes"`
RiskActions map[string]string `json:"risk_actions"`
FailureMode string `json:"failure_mode"`
BlockMessage string `json:"block_message"`
}
if err = json.Unmarshal([]byte(row.SensitiveWordPolicy), &policy); err != nil {
return nil, gerror.Wrap(err, "商户内容审核策略格式错误")
}
res.PolicyVersion = row.PolicyVersion
for scene, enabled := range policy.Scenes {
res.Scenes[scene] = enabled
}
for risk, action := range policy.RiskActions {
if action == "allow" || action == "block" || action == "audit_only" {
res.RiskActions[risk] = action
}
}
res.FailureMode = moderationFailureMode(policy.FailureMode)
if strings.TrimSpace(policy.BlockMessage) != "" {
res.BlockMessage = strings.TrimSpace(policy.BlockMessage)
}
return res, nil
}
func (c *cConfig) SecurityPolicy(ctx context.Context, req *api.SecurityPolicyReq) (res *api.SecurityPolicyRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code))
if err != nil {
return nil, err
}
res = &api.SecurityPolicyRes{
TenantID: tenant.Id,
TenantCode: tenant.Code,
GlobalFriendEnabled: true,
FriendDisabledPolicy: map[string]interface{}{},
MessageCurfewEnabled: false,
CurfewRules: []*api.SecurityCurfewRuleRes{},
}
columns := dao.ImSecurityPolicy.Columns()
record, err := imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(columns.Id, columns.PolicyVersion, columns.GlobalFriendEnabled, columns.MessageCurfewEnabled, "CAST("+columns.LoginRateLimit+" AS CHAR) login_rate_limit", "CAST("+columns.FriendDisabledPolicy+" AS CHAR) friend_disabled_policy").
Where(columns.TenantId, tenant.Id).Where(columns.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(columns.DeletedAt).OrderDesc(columns.PolicyVersion).One()
if err != nil {
return nil, gerror.Wrap(err, "获取商户安全策略失败")
}
if record.IsEmpty() {
return res, nil
}
policyID := record[columns.Id].Int64()
res.PolicyVersion = record[columns.PolicyVersion].Uint()
res.GlobalFriendEnabled = record[columns.GlobalFriendEnabled].Int() == 1
res.MessageCurfewEnabled = record[columns.MessageCurfewEnabled].Int() == 1
if raw := strings.TrimSpace(record["login_rate_limit"].String()); raw != "" && raw != "null" {
var limit struct {
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"password_max_attempts"`
LockSeconds int `json:"lock_seconds"`
WindowSeconds int `json:"window_seconds"`
}
if json.Unmarshal([]byte(raw), &limit) == nil && limit.Enabled {
res.LoginRateLimit = &api.SecurityLoginRateLimitRes{
Enabled: true,
PasswordMaxAttempts: limit.PasswordMaxAttempts,
LockSeconds: limit.LockSeconds,
WindowSeconds: limit.WindowSeconds,
}
}
}
if raw := strings.TrimSpace(record["friend_disabled_policy"].String()); raw != "" && raw != "null" {
_ = json.Unmarshal([]byte(raw), &res.FriendDisabledPolicy)
}
ruleColumns := dao.ImMessageCurfewRules.Columns()
var rows []struct {
RuleCode string `json:"rule_code"`
RuleName string `json:"rule_name"`
DateType string `json:"date_type"`
CustomDates string `json:"custom_dates"`
Weekdays string `json:"weekdays"`
Timezone string `json:"timezone"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Scope string `json:"scope"`
WhitelistUserIds string `json:"whitelist_user_ids"`
WhitelistGroupIds string `json:"whitelist_group_ids"`
Message string `json:"message"`
Status int `json:"status"`
Sort int `json:"sort"`
}
if err = imtenant.RegistryDB().Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).
Fields(ruleColumns.RuleCode, ruleColumns.RuleName, ruleColumns.DateType, "CAST("+ruleColumns.CustomDates+" AS CHAR) custom_dates", "CAST("+ruleColumns.Weekdays+" AS CHAR) weekdays", ruleColumns.Timezone, "TIME_FORMAT("+ruleColumns.StartTime+",'%H:%i') start_time", "TIME_FORMAT("+ruleColumns.EndTime+",'%H:%i') end_time", ruleColumns.Scope, "CAST("+ruleColumns.WhitelistUserIds+" AS CHAR) whitelist_user_ids", "CAST("+ruleColumns.WhitelistGroupIds+" AS CHAR) whitelist_group_ids", ruleColumns.Message, ruleColumns.Status, ruleColumns.Sort).
Where(ruleColumns.PolicyId, policyID).Where(ruleColumns.Status, consts.StatusEnabled).
WhereNull(ruleColumns.DeletedAt).OrderAsc(ruleColumns.Sort).OrderAsc(ruleColumns.Id).Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "获取商户宵禁规则失败")
}
for _, row := range rows {
res.CurfewRules = append(res.CurfewRules, &api.SecurityCurfewRuleRes{
RuleCode: row.RuleCode,
RuleName: row.RuleName,
DateType: row.DateType,
CustomDates: parseStringArray(row.CustomDates),
Weekdays: parseIntArray(row.Weekdays),
Timezone: row.Timezone,
StartTime: row.StartTime,
EndTime: row.EndTime,
Scope: row.Scope,
WhitelistUserIds: parseStringArray(row.WhitelistUserIds),
WhitelistGroupIds: parseStringArray(row.WhitelistGroupIds),
Message: row.Message,
Status: row.Status,
Sort: row.Sort,
})
}
return res, nil
}
func (c *cConfig) RealnamePolicy(ctx context.Context, req *api.RealnamePolicyReq) (res *api.RealnamePolicyRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Id, req.Id)
if err != nil {
return nil, err
}
res = defaultRealnamePolicy(tenant.Id)
var row *struct {
RealnameReviewPolicy int `json:"realname_review_policy"`
}
if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields("realname_review_policy").
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&row); err != nil {
return nil, gerror.Wrap(err, "获取租户实名认证策略失败")
}
if row == nil {
return res, nil
}
if row.RealnameReviewPolicy == 1 {
res.RealnameReviewPolicy = 1
}
return res, nil
}
func parseStringArray(raw string) []string {
values := []string{}
if strings.TrimSpace(raw) == "" {
return values
}
_ = json.Unmarshal([]byte(raw), &values)
return values
}
func parseIntArray(raw string) []int {
values := []int{}
if strings.TrimSpace(raw) == "" {
return values
}
_ = json.Unmarshal([]byte(raw), &values)
return values
}
func (c *cConfig) SensitiveWords(ctx context.Context, req *api.SensitiveWordsReq) (res *api.SensitiveWordsRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code))
if err != nil {
return nil, err
}
res = &api.SensitiveWordsRes{TenantID: tenant.Id, TenantCode: tenant.Code, Items: []*api.SensitiveWordModel{}}
columns := dao.ImSensitiveWords.Columns()
groupColumns := dao.ImSensitiveWordGroups.Columns()
var rows []struct {
ID int64 `json:"id"`
Word string `json:"word"`
MatchType string `json:"match_type"`
SceneJSON string `json:"scene_json"`
Action string `json:"action"`
Replacement string `json:"replacement"`
RiskLevel string `json:"risk_level"`
Priority int `json:"priority"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
err = imtenant.RegistryDB().Model(dao.ImSensitiveWords.Table()+" w").Safe().Ctx(ctx).
LeftJoin(dao.ImSensitiveWordGroups.Table()+" g", "g."+groupColumns.Id+"=w."+columns.GroupId).
Fields("w."+columns.Id, "w."+columns.Word, "w."+columns.MatchType, "CAST(w."+columns.SceneJson+" AS CHAR) scene_json", "w."+columns.Action, "w."+columns.Replacement, "w."+columns.RiskLevel, "w."+columns.Priority, "w."+columns.UpdatedAt).
Where("w."+columns.TenantId, tenant.Id).Where("w."+columns.Status, consts.StatusEnabled).WhereNull("w."+columns.DeletedAt).
Where("g."+groupColumns.Status, consts.StatusEnabled).WhereNull("g." + groupColumns.DeletedAt).
OrderDesc("w." + columns.Priority).OrderAsc("w." + columns.Id).Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "获取商户有效敏感词失败")
}
for _, row := range rows {
var scenes []string
_ = json.Unmarshal([]byte(row.SceneJSON), &scenes)
res.Items = append(res.Items, &api.SensitiveWordModel{ID: row.ID, Word: row.Word, MatchType: row.MatchType, Scenes: scenes, Action: row.Action, Replacement: row.Replacement, RiskLevel: row.RiskLevel, Priority: row.Priority})
if row.UpdatedAt != nil && row.UpdatedAt.TimestampMilli() > res.Version {
res.Version = row.UpdatedAt.TimestampMilli()
}
}
return res, nil
}
func (c *cConfig) MessageKeyActive(ctx context.Context, req *api.MessageKeyActiveReq) (res *api.MessageKeyActiveRes, err error) {
key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode})
if err != nil {
return nil, err
}
return &api.MessageKeyActiveRes{ImMessageKeyModel: key}, nil
}
func (c *cConfig) MessageKeyVersion(ctx context.Context, req *api.MessageKeyVersionReq) (res *api.MessageKeyVersionRes, err error) {
key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode, Version: req.Version})
if err != nil {
return nil, err
}
return &api.MessageKeyVersionRes{ImMessageKeyModel: key}, nil
}
func (c *cConfig) MessageKeyAck(ctx context.Context, req *api.MessageKeyAckReq) (res *api.MessageKeyAckRes, err error) {
err = service.SysImMessageEncryption().Ack(ctx, &sysin.ImMessageKeyAckInp{TenantCode: req.TenantCode, Version: req.Version})
return
}
func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes {
return &api.ModerationPolicyRes{TenantID: tenant.Id, TenantCode: tenant.Code, Enabled: false,
AliyunEnabled: false, LocalWordsEnabled: false, Scenes: cloneBoolMap(defaultModerationScenes),
RiskActions: cloneStringMap(defaultModerationRiskActions), FailureMode: "allow",
BlockMessage: "内容不符合平台规范,请修改后重试"}
}
func defaultRealnamePolicy(tenantID int64) *api.RealnamePolicyRes {
return &api.RealnamePolicyRes{
TenantID: tenantID,
RealnameReviewPolicy: 0,
}
}
func cloneBoolMap(src map[string]bool) map[string]bool {
dst := make(map[string]bool, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func cloneStringMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func stringValue(values map[string]any, key, fallback string) string {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
func intValue(values map[string]any, key string, fallback int) int {
value := gconv.Int(values[key])
if value <= 0 {
return fallback
}
return value
}
func moderationFailureMode(value string) string {
if strings.TrimSpace(value) == "block" {
return "block"
}
return "allow"
}
func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLoginIPWhitelistReq) (res *api.TenantLoginIPWhitelistRes, err error) {
if _, err = getTenant(ctx, dao.ImTenant.Columns().Id, req.Id); err != nil {
return nil, err
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// adminOperationLogDao is the data access object for the table cv_admin_operation_log.
// You can define custom methods on it to extend its functionality as needed.
type adminOperationLogDao struct {
*internal.AdminOperationLogDao
}
var (
// AdminOperationLog is a globally accessible object for table cv_admin_operation_log operations.
AdminOperationLog = adminOperationLogDao{internal.NewAdminOperationLogDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// contentAuditLogDao is the data access object for the table cv_content_audit_log.
// You can define custom methods on it to extend its functionality as needed.
type contentAuditLogDao struct {
*internal.ContentAuditLogDao
}
var (
// ContentAuditLog is a globally accessible object for table cv_content_audit_log operations.
ContentAuditLog = contentAuditLogDao{internal.NewContentAuditLogDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// feedCommentDao is the data access object for the table cv_feed_comment.
// You can define custom methods on it to extend its functionality as needed.
type feedCommentDao struct {
*internal.FeedCommentDao
}
var (
// FeedComment is a globally accessible object for table cv_feed_comment operations.
FeedComment = feedCommentDao{internal.NewFeedCommentDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// feedMediaDao is the data access object for the table cv_feed_media.
// You can define custom methods on it to extend its functionality as needed.
type feedMediaDao struct {
*internal.FeedMediaDao
}
var (
// FeedMedia is a globally accessible object for table cv_feed_media operations.
FeedMedia = feedMediaDao{internal.NewFeedMediaDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// feedPostDao is the data access object for the table cv_feed_post.
// You can define custom methods on it to extend its functionality as needed.
type feedPostDao struct {
*internal.FeedPostDao
}
var (
// FeedPost is a globally accessible object for table cv_feed_post operations.
FeedPost = feedPostDao{internal.NewFeedPostDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// feedReportDao is the data access object for the table cv_feed_report.
// You can define custom methods on it to extend its functionality as needed.
type feedReportDao struct {
*internal.FeedReportDao
}
var (
// FeedReport is a globally accessible object for table cv_feed_report operations.
FeedReport = feedReportDao{internal.NewFeedReportDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// flowerCatalogDao is the data access object for the table cv_flower_catalog.
// You can define custom methods on it to extend its functionality as needed.
type flowerCatalogDao struct {
*internal.FlowerCatalogDao
}
var (
// FlowerCatalog is a globally accessible object for table cv_flower_catalog operations.
FlowerCatalog = flowerCatalogDao{internal.NewFlowerCatalogDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// flowerStageRuleDao is the data access object for the table cv_flower_stage_rule.
// You can define custom methods on it to extend its functionality as needed.
type flowerStageRuleDao struct {
*internal.FlowerStageRuleDao
}
var (
// FlowerStageRule is a globally accessible object for table cv_flower_stage_rule operations.
FlowerStageRule = flowerStageRuleDao{internal.NewFlowerStageRuleDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,103 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// AdminOperationLogDao is the data access object for the table cv_admin_operation_log.
type AdminOperationLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns AdminOperationLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// AdminOperationLogColumns defines and stores column names for the table cv_admin_operation_log.
type AdminOperationLogColumns struct {
Id string //
TenantId string //
OperatorId string //
OperationType string //
TargetType string //
TargetId string //
RequestId string //
BeforeSnapshot string //
AfterSnapshot string //
Reason string //
IpAddress string //
UserAgent string //
CreatedAt string //
}
// adminOperationLogColumns holds the columns for the table cv_admin_operation_log.
var adminOperationLogColumns = AdminOperationLogColumns{
Id: "id",
TenantId: "tenant_id",
OperatorId: "operator_id",
OperationType: "operation_type",
TargetType: "target_type",
TargetId: "target_id",
RequestId: "request_id",
BeforeSnapshot: "before_snapshot",
AfterSnapshot: "after_snapshot",
Reason: "reason",
IpAddress: "ip_address",
UserAgent: "user_agent",
CreatedAt: "created_at",
}
// NewAdminOperationLogDao creates and returns a new DAO object for table data access.
func NewAdminOperationLogDao(handlers ...gdb.ModelHandler) *AdminOperationLogDao {
return &AdminOperationLogDao{
group: "cultivate",
table: "cv_admin_operation_log",
columns: adminOperationLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *AdminOperationLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *AdminOperationLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *AdminOperationLogDao) Columns() AdminOperationLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *AdminOperationLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *AdminOperationLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *AdminOperationLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,103 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// ContentAuditLogDao is the data access object for the table cv_content_audit_log.
type ContentAuditLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns ContentAuditLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// ContentAuditLogColumns defines and stores column names for the table cv_content_audit_log.
type ContentAuditLogColumns struct {
Id string //
TenantId string //
ObjectType string //
ObjectId string //
AuditRequestId string //
AuditSource string //
StatusBefore string //
StatusAfter string //
ResultCode string //
RiskLabels string //
OperatorId string //
OccurredAt string //
CreatedAt string //
}
// contentAuditLogColumns holds the columns for the table cv_content_audit_log.
var contentAuditLogColumns = ContentAuditLogColumns{
Id: "id",
TenantId: "tenant_id",
ObjectType: "object_type",
ObjectId: "object_id",
AuditRequestId: "audit_request_id",
AuditSource: "audit_source",
StatusBefore: "status_before",
StatusAfter: "status_after",
ResultCode: "result_code",
RiskLabels: "risk_labels",
OperatorId: "operator_id",
OccurredAt: "occurred_at",
CreatedAt: "created_at",
}
// NewContentAuditLogDao creates and returns a new DAO object for table data access.
func NewContentAuditLogDao(handlers ...gdb.ModelHandler) *ContentAuditLogDao {
return &ContentAuditLogDao{
group: "cultivate",
table: "cv_content_audit_log",
columns: contentAuditLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *ContentAuditLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *ContentAuditLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *ContentAuditLogDao) Columns() ContentAuditLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *ContentAuditLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *ContentAuditLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *ContentAuditLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,105 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FeedCommentDao is the data access object for the table cv_feed_comment.
type FeedCommentDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedCommentColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedCommentColumns defines and stores column names for the table cv_feed_comment.
type FeedCommentColumns struct {
Id string //
TenantId string //
PostId string //
AuthorId string //
ContentText string //
Status string //
AuditStatus string //
AuditRequestId string //
DeletedBy string //
DeleteReason string //
PublishedAt string //
DeletedAt string //
CreatedAt string //
UpdatedAt string //
}
// feedCommentColumns holds the columns for the table cv_feed_comment.
var feedCommentColumns = FeedCommentColumns{
Id: "id",
TenantId: "tenant_id",
PostId: "post_id",
AuthorId: "author_id",
ContentText: "content_text",
Status: "status",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
DeletedBy: "deleted_by",
DeleteReason: "delete_reason",
PublishedAt: "published_at",
DeletedAt: "deleted_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedCommentDao creates and returns a new DAO object for table data access.
func NewFeedCommentDao(handlers ...gdb.ModelHandler) *FeedCommentDao {
return &FeedCommentDao{
group: "cultivate",
table: "cv_feed_comment",
columns: feedCommentColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedCommentDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedCommentDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedCommentDao) Columns() FeedCommentColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedCommentDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedCommentDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedCommentDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+105
View File
@@ -0,0 +1,105 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FeedMediaDao is the data access object for the table cv_feed_media.
type FeedMediaDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedMediaColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedMediaColumns defines and stores column names for the table cv_feed_media.
type FeedMediaColumns struct {
Id string //
TenantId string //
PostId string //
UserId string //
MediaType string //
FileId string //
ThumbnailFileId string //
Width string //
Height string //
SortNo string //
AuditStatus string //
AuditRequestId string //
CreatedAt string //
UpdatedAt string //
}
// feedMediaColumns holds the columns for the table cv_feed_media.
var feedMediaColumns = FeedMediaColumns{
Id: "id",
TenantId: "tenant_id",
PostId: "post_id",
UserId: "user_id",
MediaType: "media_type",
FileId: "file_id",
ThumbnailFileId: "thumbnail_file_id",
Width: "width",
Height: "height",
SortNo: "sort_no",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedMediaDao creates and returns a new DAO object for table data access.
func NewFeedMediaDao(handlers ...gdb.ModelHandler) *FeedMediaDao {
return &FeedMediaDao{
group: "cultivate",
table: "cv_feed_media",
columns: feedMediaColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedMediaDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedMediaDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedMediaDao) Columns() FeedMediaColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedMediaDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedMediaDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedMediaDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+117
View File
@@ -0,0 +1,117 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FeedPostDao is the data access object for the table cv_feed_post.
type FeedPostDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedPostColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedPostColumns defines and stores column names for the table cv_feed_post.
type FeedPostColumns struct {
Id string //
TenantId string //
AuthorId string //
ContentText string //
FlowerCode string //
SourceType string //
SourceId string //
Visibility string //
Status string //
AuditStatus string //
AuditRequestId string //
RejectReasonCode string //
LikeCount string //
CommentCount string //
Version string //
PublishedAt string //
DeletedAt string //
TakenDownAt string //
CreatedAt string //
UpdatedAt string //
}
// feedPostColumns holds the columns for the table cv_feed_post.
var feedPostColumns = FeedPostColumns{
Id: "id",
TenantId: "tenant_id",
AuthorId: "author_id",
ContentText: "content_text",
FlowerCode: "flower_code",
SourceType: "source_type",
SourceId: "source_id",
Visibility: "visibility",
Status: "status",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
RejectReasonCode: "reject_reason_code",
LikeCount: "like_count",
CommentCount: "comment_count",
Version: "version",
PublishedAt: "published_at",
DeletedAt: "deleted_at",
TakenDownAt: "taken_down_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedPostDao creates and returns a new DAO object for table data access.
func NewFeedPostDao(handlers ...gdb.ModelHandler) *FeedPostDao {
return &FeedPostDao{
group: "cultivate",
table: "cv_feed_post",
columns: feedPostColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedPostDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedPostDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedPostDao) Columns() FeedPostColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedPostDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedPostDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedPostDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+109
View File
@@ -0,0 +1,109 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FeedReportDao is the data access object for the table cv_feed_report.
type FeedReportDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedReportColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedReportColumns defines and stores column names for the table cv_feed_report.
type FeedReportColumns struct {
Id string //
TenantId string //
ReporterId string //
ReportedUserId string //
ObjectType string //
ObjectId string //
ReasonCode string //
Description string //
EvidenceFileIds string //
SourcePage string //
Status string //
HandlerId string //
HandleResult string //
HandledAt string //
CreatedAt string //
UpdatedAt string //
}
// feedReportColumns holds the columns for the table cv_feed_report.
var feedReportColumns = FeedReportColumns{
Id: "id",
TenantId: "tenant_id",
ReporterId: "reporter_id",
ReportedUserId: "reported_user_id",
ObjectType: "object_type",
ObjectId: "object_id",
ReasonCode: "reason_code",
Description: "description",
EvidenceFileIds: "evidence_file_ids",
SourcePage: "source_page",
Status: "status",
HandlerId: "handler_id",
HandleResult: "handle_result",
HandledAt: "handled_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedReportDao creates and returns a new DAO object for table data access.
func NewFeedReportDao(handlers ...gdb.ModelHandler) *FeedReportDao {
return &FeedReportDao{
group: "cultivate",
table: "cv_feed_report",
columns: feedReportColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedReportDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedReportDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedReportDao) Columns() FeedReportColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedReportDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedReportDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedReportDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,109 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FlowerCatalogDao is the data access object for the table cv_flower_catalog.
type FlowerCatalogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FlowerCatalogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FlowerCatalogColumns defines and stores column names for the table cv_flower_catalog.
type FlowerCatalogColumns struct {
Id string //
TenantId string //
FlowerCode string //
FlowerName string //
FlowerDesc string //
IconFileId string //
Status string //
ConfigVersion string //
DailyWaterLimit string //
DailyFertilizeLimit string //
ShareConfig string //
SortNo string //
OnlineAt string //
OfflineAt string //
CreatedAt string //
UpdatedAt string //
}
// flowerCatalogColumns holds the columns for the table cv_flower_catalog.
var flowerCatalogColumns = FlowerCatalogColumns{
Id: "id",
TenantId: "tenant_id",
FlowerCode: "flower_code",
FlowerName: "flower_name",
FlowerDesc: "flower_desc",
IconFileId: "icon_file_id",
Status: "status",
ConfigVersion: "config_version",
DailyWaterLimit: "daily_water_limit",
DailyFertilizeLimit: "daily_fertilize_limit",
ShareConfig: "share_config",
SortNo: "sort_no",
OnlineAt: "online_at",
OfflineAt: "offline_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFlowerCatalogDao creates and returns a new DAO object for table data access.
func NewFlowerCatalogDao(handlers ...gdb.ModelHandler) *FlowerCatalogDao {
return &FlowerCatalogDao{
group: "cultivate",
table: "cv_flower_catalog",
columns: flowerCatalogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FlowerCatalogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FlowerCatalogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FlowerCatalogDao) Columns() FlowerCatalogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FlowerCatalogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FlowerCatalogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FlowerCatalogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,109 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// FlowerStageRuleDao is the data access object for the table cv_flower_stage_rule.
type FlowerStageRuleDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FlowerStageRuleColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FlowerStageRuleColumns defines and stores column names for the table cv_flower_stage_rule.
type FlowerStageRuleColumns struct {
Id string //
TenantId string //
FlowerCatalogId string //
Stage string //
NextStage string //
RequiredPoints string //
WaterPoints string //
FertilizePoints string //
AllowedActions string //
ImageFileId string //
AnimationFileId string //
ProgressTextTemplate string //
CompletionText string //
SortNo string //
CreatedAt string //
UpdatedAt string //
}
// flowerStageRuleColumns holds the columns for the table cv_flower_stage_rule.
var flowerStageRuleColumns = FlowerStageRuleColumns{
Id: "id",
TenantId: "tenant_id",
FlowerCatalogId: "flower_catalog_id",
Stage: "stage",
NextStage: "next_stage",
RequiredPoints: "required_points",
WaterPoints: "water_points",
FertilizePoints: "fertilize_points",
AllowedActions: "allowed_actions",
ImageFileId: "image_file_id",
AnimationFileId: "animation_file_id",
ProgressTextTemplate: "progress_text_template",
CompletionText: "completion_text",
SortNo: "sort_no",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFlowerStageRuleDao creates and returns a new DAO object for table data access.
func NewFlowerStageRuleDao(handlers ...gdb.ModelHandler) *FlowerStageRuleDao {
return &FlowerStageRuleDao{
group: "cultivate",
table: "cv_flower_stage_rule",
columns: flowerStageRuleColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FlowerStageRuleDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FlowerStageRuleDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FlowerStageRuleDao) Columns() FlowerStageRuleColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FlowerStageRuleDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FlowerStageRuleDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FlowerStageRuleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+14 -1
View File
@@ -41,7 +41,7 @@ func Authorize(r *ghttp.Request) bool {
signature := strings.TrimSpace(r.Header.Get(headerSignature))
expectedService := g.Cfg().MustGet(ctx, "imConfigApi.serviceName", "im-server").String()
secret := configuredSecret(r)
if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || serviceName != expectedService || secret == "" {
if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || !isAllowedService(serviceName, expectedService) || secret == "" {
writeAuthError(r, http.StatusUnauthorized, "internal signature required")
return false
}
@@ -85,6 +85,19 @@ func Authorize(r *ghttp.Request) bool {
return true
}
func isAllowedService(serviceName, expectedServices string) bool {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
return false
}
for _, expected := range strings.Split(expectedServices, ",") {
if serviceName == strings.TrimSpace(expected) {
return true
}
}
return false
}
func configuredSecret(r *ghttp.Request) string {
ctx := r.GetCtx()
secret := g.Cfg().MustGet(ctx, "imConfigApi.serviceSecret").String()
@@ -0,0 +1,76 @@
package immessagecrypto
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/errors/gerror"
)
func GenerateDEK() ([]byte, error) {
key := make([]byte, encrypt.AESGCMKeySize)
if _, err := rand.Read(key); err != nil {
return nil, gerror.Wrap(err, "生成商户消息密钥失败")
}
return key, nil
}
func WrapDEK(ctx context.Context, tenantID int64, version uint, dek []byte) (ciphertext, nonce []byte, err error) {
kek, err := loadRootKEK(ctx)
if err != nil {
return nil, nil, err
}
return encrypt.AESGCMEncrypt(dek, kek, keyAAD(tenantID, version))
}
func UnwrapDEK(ctx context.Context, tenantID int64, version uint, ciphertext, nonce []byte) ([]byte, error) {
kek, err := loadRootKEK(ctx)
if err != nil {
return nil, err
}
dek, err := encrypt.AESGCMDecrypt(ciphertext, nonce, kek, keyAAD(tenantID, version))
if err != nil {
return nil, gerror.Wrap(err, "解包商户消息密钥失败")
}
if len(dek) != encrypt.AESGCMKeySize {
return nil, gerror.New("商户消息密钥长度不正确")
}
return dek, nil
}
func EncodeDEK(dek []byte) string {
return base64.StdEncoding.EncodeToString(dek)
}
func loadRootKEK(ctx context.Context) ([]byte, error) {
columns := dao.SysDictData.Columns()
value, err := dao.SysDictData.Ctx(ctx).
Fields(columns.Value).
Where(columns.Type, consts.ImInternalSecretDictType).
Where(columns.Label, consts.ImMessageRootKEKLabel).
Where(columns.Status, consts.StatusEnabled).
Value()
if err != nil {
return nil, gerror.Wrap(err, "读取消息根密钥失败")
}
if value.IsEmpty() {
return nil, gerror.New("消息根密钥未初始化")
}
kek, err := base64.StdEncoding.DecodeString(value.String())
if err != nil || len(kek) != encrypt.AESGCMKeySize {
return nil, gerror.New("消息根密钥格式不正确")
}
return kek, nil
}
func keyAAD(tenantID int64, version uint) []byte {
aad := make([]byte, 16)
binary.BigEndian.PutUint64(aad[:8], uint64(tenantID))
binary.BigEndian.PutUint64(aad[8:], uint64(version))
return aad
}
@@ -0,0 +1,57 @@
package immoderation
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
)
type AliyunConfig struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
Service string
Timeout time.Duration
}
type CheckResult struct {
RequestID string
Latency time.Duration
}
func CheckAliyunText(ctx context.Context, config AliyunConfig) (*CheckResult, error) {
if strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" {
return nil, errors.New("AccessKey ID或AccessKey Secret未配置")
}
clientConfig := &openapi.Config{AccessKeyId: dara.String(config.AccessKeyID), AccessKeySecret: dara.String(config.AccessKeySecret), RegionId: dara.String(config.RegionID), Protocol: dara.String("https")}
if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" {
clientConfig.Endpoint = dara.String(endpoint)
}
client, err := green.NewClient(clientConfig)
if err != nil {
return nil, err
}
if config.Timeout <= 0 {
config.Timeout = 1500 * time.Millisecond
}
ctx, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
params, _ := json.Marshal(map[string]string{"content": "内容审核连通性测试", "dataId": "admin-config-check"})
started := time.Now()
resp, err := client.TextModerationPlusWithContext(ctx, &green.TextModerationPlusRequest{Service: dara.String(config.Service), ServiceParameters: dara.String(string(params))}, &dara.RuntimeOptions{})
latency := time.Since(started)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || dara.Int32Value(resp.Body.Code) != 200 {
return nil, errors.New("阿里云内容审核返回失败")
}
return &CheckResult{RequestID: dara.StringValue(resp.Body.RequestId), Latency: latency}, nil
}
@@ -0,0 +1,63 @@
package immoderation
import (
"context"
"hotgo/internal/dao"
"hotgo/internal/model/entity"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
)
const (
AliyunConfigGroup = "im_third_party"
AliyunAccessKeyIDKey = "aliyun_access_key_id"
AliyunAccessKeySecretKey = "aliyun_access_key_secret"
AliyunGreenRegionIDKey = "aliyun_green_region_id"
AliyunGreenEndpointKey = "aliyun_green_endpoint"
DefaultAliyunGreenRegionID = "cn-shanghai"
DefaultAliyunGreenEndpoint = "green-cip.cn-shanghai.aliyuncs.com"
)
type AliyunGlobalConfig struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
}
func LoadAliyunGlobalConfig(ctx context.Context) (config AliyunGlobalConfig, err error) {
columns := dao.SysConfig.Columns()
keys := []string{
AliyunAccessKeyIDKey,
AliyunAccessKeySecretKey,
AliyunGreenRegionIDKey,
AliyunGreenEndpointKey,
}
var rows []*entity.SysConfig
if err = dao.SysConfig.Ctx(ctx).
Fields(columns.Key, columns.Value).
Where(columns.Group, AliyunConfigGroup).
Where(columns.Status, 1).
WhereIn(columns.Key, keys).
Scan(&rows); err != nil {
return config, gerror.Wrap(err, "获取阿里云内容审核公共配置失败")
}
values := make(map[string]string, len(rows))
for _, row := range rows {
values[row.Key] = strings.TrimSpace(row.Value)
}
return AliyunGlobalConfig{
AccessKeyID: values[AliyunAccessKeyIDKey],
AccessKeySecret: values[AliyunAccessKeySecretKey],
RegionID: firstValue(values[AliyunGreenRegionIDKey], DefaultAliyunGreenRegionID),
Endpoint: firstValue(values[AliyunGreenEndpointKey], DefaultAliyunGreenEndpoint),
}, nil
}
func firstValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
+6 -1
View File
@@ -95,7 +95,12 @@ func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) {
return normalizeMySQLTimeOptions(tenant.DbDsn)
}
if tenant.DbName == "" {
return "", gerror.New("租户业务库未配置")
// 未配置独立业务库的商户回落共享默认库
defaultLink := defaultDbLink(ctx)
if defaultLink == "" {
return "", gerror.New("默认数据库连接未配置,无法回落共享业务库")
}
return normalizeMySQLTimeOptions(defaultLink)
}
defaultLink := defaultDbLink(ctx)
@@ -0,0 +1,199 @@
package imworkbench
import (
"bytes"
"context"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
const maxResponseBody = 10 << 20
type Config struct {
BaseURL string
ServiceName string
Secret string
Timeout time.Duration
}
type Client struct {
conf Config
http *http.Client
}
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
func DefaultClient(ctx context.Context) *Client {
timeout := g.Cfg().MustGet(ctx, "imWorkbenchApi.timeout", "5s").Duration()
if timeout <= 0 {
timeout = 5 * time.Second
}
secret := firstSecret(
g.Cfg().MustGet(ctx, "imWorkbenchApi.serviceSecret").String(),
os.Getenv("IM_WORKBENCH_API_SECRET"),
g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String(),
os.Getenv("IM_INTERNAL_API_SECRET"),
g.Cfg().MustGet(ctx, "imAdminApi.serviceSecret").String(),
os.Getenv("IM_ADMIN_API_SECRET"),
)
conf := Config{
BaseURL: g.Cfg().MustGet(ctx, "imWorkbenchApi.baseUrl").String(),
ServiceName: g.Cfg().MustGet(ctx, "imWorkbenchApi.serviceName", "hotgo-admin").String(),
Secret: secret,
Timeout: timeout,
}
return &Client{conf: conf, http: &http.Client{Timeout: timeout}}
}
func (c *Client) Get(ctx context.Context, path string, values url.Values, out any) error {
if values != nil {
if query := values.Encode(); query != "" {
path += "?" + query
}
}
return c.doJSON(ctx, http.MethodGet, path, nil, out)
}
func (c *Client) Post(ctx context.Context, path string, in any, out any) error {
body, err := json.Marshal(in)
if err != nil {
return gerror.Wrap(err, "序列化工作台请求失败")
}
return c.doJSON(ctx, http.MethodPost, path, body, out)
}
func (c *Client) doJSON(ctx context.Context, method string, path string, body []byte, out any) error {
req, err := c.newRequest(ctx, method, path, body)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
g.Log().Warningf(ctx, "IM workbench request failed url:%s err:%+v", req.URL.String(), err)
return gerror.New("IM工作台服务暂不可用")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseError(resp)
}
if out == nil {
return nil
}
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return gerror.Wrap(err, "读取IM工作台响应失败")
}
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Reason string `json:"reason"`
Data json.RawMessage `json:"data"`
}
if err = json.Unmarshal(responseBody, &envelope); err == nil && len(envelope.Data) > 0 {
if envelope.Code != 0 {
return stableError(envelope.Message, envelope.Reason)
}
return json.Unmarshal(envelope.Data, out)
}
return json.Unmarshal(responseBody, out)
}
func (c *Client) newRequest(ctx context.Context, method string, path string, body []byte) (*http.Request, error) {
baseURL := strings.TrimRight(c.conf.BaseURL, "/")
if baseURL == "" {
return nil, gerror.New("IM工作台API未配置")
}
if c.conf.Secret == "" {
return nil, gerror.New("IM工作台API密钥未配置")
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, gerror.Wrap(err, "创建IM工作台请求失败")
}
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
nonce, err := randomNonce()
if err != nil {
return nil, err
}
bodyHash := sha256.Sum256(body)
signingText := strings.Join([]string{
method,
req.URL.RequestURI(),
hex.EncodeToString(bodyHash[:]),
timestamp,
nonce,
}, "\n")
mac := hmac.New(sha256.New, []byte(c.conf.Secret))
_, _ = mac.Write([]byte(signingText))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Name", c.conf.ServiceName)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
req.Header.Set("X-Request-ID", gctx.CtxId(ctx))
return req, nil
}
func parseError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
var envelope struct {
Message string `json:"message"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(body, &envelope)
return stableError(envelope.Message, envelope.Reason)
}
func stableError(message string, reason string) error {
if message != "" {
return gerror.New(message)
}
if reason != "" {
return gerror.New(reason)
}
return gerror.New("IM工作台服务暂不可用")
}
func randomNonce() (string, error) {
var b [16]byte
if _, err := cryptorand.Read(b[:]); err != nil {
return "", gerror.Wrap(err, "生成IM工作台请求随机数失败")
}
return hex.EncodeToString(b[:]), nil
}
func firstSecret(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
value = strings.TrimSpace(os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")))
}
if value != "" {
return value
}
}
return ""
}
+52 -4
View File
@@ -468,7 +468,7 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err
return
}
if service.AdminMember().VerifySuperId(ctx, opMemberId) {
if service.AdminMember().VerifySuperId(ctx, opMemberId) || contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey {
if in.TenantId > 0 {
count, queryErr := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().Id, in.TenantId).
@@ -508,6 +508,9 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err
if err = service.AdminRole().VerifyRoleId(ctx, in.RoleId); err != nil {
return
}
if err = s.normalizeTenantByRole(ctx, in); err != nil {
return
}
// 验证部门ID
if err = service.AdminDept().VerifyDeptId(ctx, in.DeptId); err != nil {
@@ -606,6 +609,50 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err
})
}
func (s *sAdminMember) normalizeTenantByRole(ctx context.Context, in *adminin.MemberEditInp) error {
var role *entity.AdminRole
if err := dao.AdminRole.Ctx(ctx).
Fields(dao.AdminRole.Columns().Id, dao.AdminRole.Columns().Key).
WherePri(in.RoleId).
Scan(&role); err != nil {
return gerror.Wrap(err, "获取角色信息失败,请稍后重试!")
}
if role == nil || role.Id <= 0 {
return gerror.New("角色不存在")
}
switch role.Key {
case consts.ImPlatformRoleKey:
deptId, err := s.getCompanyDeptId(ctx)
if err != nil {
return err
}
in.TenantId = 0
in.DeptId = deptId
case "imManage":
if in.TenantId <= 0 {
return gerror.New("IM管理员必须选择所属商户")
}
}
return nil
}
func (s *sAdminMember) getCompanyDeptId(ctx context.Context) (int64, error) {
value, err := dao.AdminDept.Ctx(ctx).
Fields(dao.AdminDept.Columns().Id).
Where(dao.AdminDept.Columns().Type, consts.DeptTypeCompany).
Where(dao.AdminDept.Columns().Status, consts.StatusEnabled).
OrderAsc(dao.AdminDept.Columns().Id).
Limit(1).
Value()
if err != nil {
return 0, gerror.Wrap(err, "获取公司部门失败,请稍后重试!")
}
if value.IsEmpty() {
return 0, gerror.New("请先配置公司部门")
}
return value.Int64(), nil
}
// View 获取用户信息
func (s *sAdminMember) View(ctx context.Context, in *adminin.MemberViewInp) (res *adminin.MemberViewModel, err error) {
if err = s.FilterAuthModel(ctx, contexts.GetUserId(ctx)).Hook(hook.MemberInfo).WherePri(in.Id).Scan(&res); err != nil {
@@ -912,9 +959,6 @@ func (s *sAdminMember) ClusterSyncSuperAdmin(ctx context.Context, message *gredi
// 非超管用户只能操作自己的下级角色用户,并且需要满足自身角色的数据权限设置
func (s *sAdminMember) FilterAuthModel(ctx context.Context, memberId int64) *gdb.Model {
m := dao.AdminMember.Ctx(ctx)
if s.VerifySuperId(ctx, memberId) {
return m
}
var roleId int64
if contexts.GetUserId(ctx) == memberId {
@@ -929,6 +973,10 @@ func (s *sAdminMember) FilterAuthModel(ctx context.Context, memberId int64) *gdb
roleId = ro.Int64()
}
if isAdminScopeSuper(ctx, memberId, roleId) {
return m
}
roleIds, err := service.AdminRole().GetSubRoleIds(ctx, roleId, false)
if err != nil {
g.Log().Panicf(ctx, "get the subordinate role permission exception, err:%+v", err)
+39 -4
View File
@@ -37,6 +37,35 @@ func init() {
service.RegisterAdminRole(NewAdminRole())
}
func isAdminScopeSuper(ctx context.Context, memberId, roleId int64) bool {
if service.AdminMember().VerifySuperId(ctx, memberId) {
return true
}
if memberId == contexts.GetUserId(ctx) && contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey {
return true
}
if roleId <= 0 {
return false
}
value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Key).WherePri(roleId).Value()
if err != nil {
g.Log().Warningf(ctx, "query admin role key failed, roleId:%d err:%+v", roleId, err)
return false
}
return value.String() == consts.ImPlatformRoleKey
}
func getSuperRoleId(ctx context.Context) (int64, error) {
value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Id).Where(dao.AdminRole.Columns().Key, consts.SuperRoleKey).Value()
if err != nil {
return 0, err
}
return value.Int64(), nil
}
// Verify 验证权限
func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool {
var (
@@ -64,13 +93,19 @@ func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool {
// List 获取列表
func (s *sAdminRole) List(ctx context.Context, in *adminin.RoleListInp) (res *adminin.RoleListModel, totalCount int, err error) {
var (
mod = dao.AdminRole.Ctx(ctx)
mod = dao.AdminRole.Ctx(ctx).WhereNot(dao.AdminRole.Columns().Key, consts.SuperRoleKey)
models []*entity.AdminRole
pid int64 = 0
)
// 非超管只获取下级角色
if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) {
// 超管隐藏内置根角色,但从内置根角色的子级开始展示角色树。
if isAdminScopeSuper(ctx, contexts.GetUserId(ctx), contexts.GetRoleId(ctx)) {
pid, err = getSuperRoleId(ctx)
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return
}
} else {
pid = contexts.GetRoleId(ctx)
mod = mod.WhereLike(dao.AdminRole.Columns().Tree, "%"+tree.GetIdLabel(pid)+"%")
}
@@ -326,7 +361,7 @@ func (s *sAdminRole) VerifyRoleId(ctx context.Context, id int64) (err error) {
return
}
ids, err := s.GetSubRoleIds(ctx, mb.RoleId, service.AdminMember().VerifySuperId(ctx, mb.Id))
ids, err := s.GetSubRoleIds(ctx, mb.RoleId, isAdminScopeSuper(ctx, mb.Id, mb.RoleId))
if err != nil {
err = gerror.New("验证角色信息失败!")
return
@@ -0,0 +1,738 @@
package sys
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
type sSysCultivateContent struct{}
func NewSysCultivateContent() *sSysCultivateContent { return &sSysCultivateContent{} }
func init() { service.RegisterSysCultivateContent(NewSysCultivateContent()) }
func (s *sSysCultivateContent) Dashboard(ctx context.Context, _ *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error) {
tenantID := cultivateTenantID(ctx)
out := new(sysin.CultivateDashboardModel)
db := g.DB("cultivate")
queries := []struct {
target *int64
sql string
args []any
}{
{&out.PlantCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=?", []any{tenantID}},
{&out.GrowingCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='growing'", []any{tenantID}},
{&out.BloomCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='bloomed'", []any{tenantID}},
{&out.HarvestedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='harvested'", []any{tenantID}},
{&out.RemovedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='removed'", []any{tenantID}},
{&out.Active7d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 6 DAY)", []any{tenantID}},
{&out.Active30d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 29 DAY)", []any{tenantID}},
{&out.PostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published'", []any{tenantID}},
{&out.PublicPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='public'", []any{tenantID}},
{&out.FriendsPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='friends'", []any{tenantID}},
{&out.PrivatePostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='private'", []any{tenantID}},
{&out.CommentCount, "SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='published'", []any{tenantID}},
{&out.LikeCount, "SELECT COALESCE(SUM(like_count),0) FROM cv_feed_post WHERE tenant_id=?", []any{tenantID}},
{&out.TakenDownCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='taken_down'", []any{tenantID}},
{&out.PendingAuditCount, "SELECT (SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='reviewing')+(SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='reviewing')", []any{tenantID, tenantID}},
{&out.ApprovedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='published'", []any{tenantID}},
{&out.RejectedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='rejected'", []any{tenantID}},
{&out.PendingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='submitted'", []any{tenantID}},
{&out.ProcessingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='processing'", []any{tenantID}},
{&out.ProcessedReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status IN ('resolved','rejected')", []any{tenantID}},
}
for _, q := range queries {
value, err := db.GetValue(ctx, q.sql, q.args...)
if err != nil {
return nil, gerror.Wrap(err, "获取养成统计失败")
}
*q.target = value.Int64()
}
return out, nil
}
func (s *sSysCultivateContent) List(ctx context.Context, in *sysin.CultivateContentListInp) (list []*sysin.CultivateContentListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return nil, 0, err
}
tenantID := cultivateTenantID(ctx)
if in.ObjectType == "feed_comment" {
columns := dao.FeedComment.Columns()
mod := dao.FeedComment.Ctx(ctx).Where(columns.TenantId, tenantID)
if in.AuthorId != "" {
mod = mod.Where(columns.AuthorId, in.AuthorId)
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
}
if in.Keyword != "" {
mod = mod.WhereLike(columns.ContentText, "%"+strings.TrimSpace(in.Keyword)+"%")
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedComment
err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false)
if err != nil {
return nil, 0, gerror.Wrap(err, "获取评论审核列表失败")
}
list = make([]*sysin.CultivateContentListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateContentListModel{Id: strconv.FormatUint(row.Id, 10), ObjectType: "feed_comment", PostId: strconv.FormatUint(row.PostId, 10), AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, Status: row.Status, AuditStatus: row.AuditStatus, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return
}
columns := dao.FeedPost.Columns()
mod := dao.FeedPost.Ctx(ctx).Where(columns.TenantId, tenantID)
if in.AuthorId != "" {
mod = mod.Where(columns.AuthorId, in.AuthorId)
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
}
if in.Keyword != "" {
mod = mod.WhereLike(columns.ContentText, "%"+strings.TrimSpace(in.Keyword)+"%")
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedPost
err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false)
if err != nil {
return nil, 0, gerror.Wrap(err, "获取动态审核列表失败")
}
list = make([]*sysin.CultivateContentListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateContentListModel{Id: strconv.FormatUint(row.Id, 10), ObjectType: "feed_post", AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, FlowerCode: row.FlowerCode, Visibility: row.Visibility, Status: row.Status, AuditStatus: row.AuditStatus, RejectReason: row.RejectReasonCode, LikeCount: row.LikeCount, CommentCount: row.CommentCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return
}
func (s *sSysCultivateContent) View(ctx context.Context, in *sysin.CultivateContentViewInp) (*sysin.CultivateContentViewModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
}
tenantID := cultivateTenantID(ctx)
base := &sysin.CultivateContentViewModel{Media: []*sysin.CultivateMediaModel{}, Audits: []*sysin.CultivateAuditModel{}, RelatedReports: []*sysin.CultivateReportListModel{}}
if in.ObjectType == "feed_comment" {
c := dao.FeedComment.Columns()
var row entity.FeedComment
if err := dao.FeedComment.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if row.Id == 0 {
return nil, gerror.New("评论不存在")
}
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, PostId: strconv.FormatUint(row.PostId, 10), AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, Status: row.Status, AuditStatus: row.AuditStatus, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
} else {
c := dao.FeedPost.Columns()
var row entity.FeedPost
if err := dao.FeedPost.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if row.Id == 0 {
return nil, gerror.New("动态不存在")
}
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, FlowerCode: row.FlowerCode, Visibility: row.Visibility, Status: row.Status, AuditStatus: row.AuditStatus, RejectReason: row.RejectReasonCode, LikeCount: row.LikeCount, CommentCount: row.CommentCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
mc := dao.FeedMedia.Columns()
var media []*entity.FeedMedia
if err := dao.FeedMedia.Ctx(ctx).Where(g.Map{mc.TenantId: tenantID, mc.PostId: in.ObjectId}).OrderAsc(mc.SortNo).Scan(&media); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
for _, m := range media {
base.Media = append(base.Media, &sysin.CultivateMediaModel{Id: strconv.FormatUint(m.Id, 10), FileId: m.FileId, ThumbnailFileId: m.ThumbnailFileId, MediaType: m.MediaType, AuditStatus: m.AuditStatus, SortNo: m.SortNo})
}
}
ac := dao.ContentAuditLog.Columns()
var audits []*entity.ContentAuditLog
if err := dao.ContentAuditLog.Ctx(ctx).Where(g.Map{ac.TenantId: tenantID, ac.ObjectType: in.ObjectType, ac.ObjectId: in.ObjectId}).OrderDesc(ac.Id).Scan(&audits); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
for _, a := range audits {
base.Audits = append(base.Audits, &sysin.CultivateAuditModel{Id: strconv.FormatUint(a.Id, 10), AuditSource: a.AuditSource, StatusBefore: a.StatusBefore, StatusAfter: a.StatusAfter, ResultCode: a.ResultCode, RiskLabels: jsonValue(a.RiskLabels), OperatorId: optionalUint(a.OperatorId), OccurredAt: a.OccurredAt})
}
rc := dao.FeedReport.Columns()
var reports []*entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{rc.TenantId: tenantID, rc.ObjectType: in.ObjectType, rc.ObjectId: in.Id}).OrderDesc(rc.Id).Scan(&reports); err == nil {
for _, r := range reports {
base.RelatedReports = append(base.RelatedReports, reportModel(r))
}
}
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{base.AuthorId})
if p := profiles[base.AuthorId]; p != nil {
base.AuthorNickname = p.Nickname
base.AuthorAvatarFileId = p.AvatarFileId
}
return base, nil
}
func (s *sSysCultivateContent) Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
var authorID uint64
err := dao.FeedPost.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
if in.ObjectType == "feed_comment" {
var row entity.FeedComment
c := dao.FeedComment.Columns()
if e := tx.Model(dao.FeedComment.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
return e
}
authorID = row.AuthorId
return s.reviewComment(txCtx, tx, tenantID, operatorID, in)
}
var row entity.FeedPost
c := dao.FeedPost.Columns()
if e := tx.Model(dao.FeedPost.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
return e
}
authorID = row.AuthorId
return s.reviewPost(txCtx, tx, tenantID, operatorID, in)
})
if err != nil {
return err
}
if authorID > 0 {
publishCultivateNotice(ctx, tenantID, authorID, fmt.Sprintf("admin.content.%s.%s.%s", in.ObjectType, in.Id, in.Action), "cultivate.content.reviewed", "内容审核结果", cultivateReviewSummary(in.Action), map[string]any{"type": in.ObjectType, "object_type": in.ObjectType, "object_id": in.Id})
}
return nil
}
func (s *sSysCultivateContent) BatchReview(ctx context.Context, in *sysin.CultivateContentBatchReviewInp) (*sysin.CultivateContentBatchReviewModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
}
result := &sysin.CultivateContentBatchReviewModel{Failures: make(map[string]string)}
for _, id := range in.Ids {
item := &sysin.CultivateContentReviewInp{ObjectType: in.ObjectType, Id: id, Action: in.Action, Reason: in.Reason}
if err := item.Filter(ctx); err != nil {
result.FailedCount++
result.Failures[id] = err.Error()
continue
}
if err := s.Review(ctx, item); err != nil {
result.FailedCount++
result.Failures[id] = err.Error()
continue
}
result.SuccessCount++
}
return result, nil
}
func (s *sSysCultivateContent) reviewPost(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, in *sysin.CultivateContentReviewInp) error {
columns := dao.FeedPost.Columns()
var before entity.FeedPost
err := tx.Model(dao.FeedPost.Table()).Where(g.Map{columns.Id: in.ObjectId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("动态不存在")
}
now := time.Now().UTC()
data := g.Map{columns.UpdatedAt: now, columns.Version: gdb.Raw(columns.Version + "+1")}
afterStatus := before.Status
switch in.Action {
case "approve":
if before.Status != "reviewing" && before.Status != "rejected" {
return gerror.New("仅待审核或已拒绝动态可审核通过")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "approved"
data[columns.RejectReasonCode] = ""
if before.PublishedAt == nil {
data[columns.PublishedAt] = now
}
case "reject":
if before.Status != "reviewing" {
return gerror.New("仅待审核动态可拒绝")
}
afterStatus = "rejected"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "rejected"
data[columns.RejectReasonCode] = in.Reason
case "take_down":
if before.Status != "published" {
return gerror.New("仅已发布动态可下架")
}
afterStatus = "taken_down"
data[columns.Status] = afterStatus
data[columns.TakenDownAt] = now
data[columns.RejectReasonCode] = in.Reason
case "restore":
if before.Status != "taken_down" {
return gerror.New("仅已下架动态可恢复")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.TakenDownAt] = nil
data[columns.RejectReasonCode] = ""
}
if _, err = tx.Model(dao.FeedPost.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新动态审核状态失败")
}
if err = writeContentAudit(ctx, tx, tenantID, operatorID, "feed_post", before.Id, before.Status, afterStatus, in.Action, now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content."+in.Action, "feed_post", in.Id, before, data, in.Reason, now)
}
func (s *sSysCultivateContent) reviewComment(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, in *sysin.CultivateContentReviewInp) error {
columns := dao.FeedComment.Columns()
var before entity.FeedComment
err := tx.Model(dao.FeedComment.Table()).Where(g.Map{columns.Id: in.ObjectId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("评论不存在")
}
now := time.Now().UTC()
data := g.Map{columns.UpdatedAt: now}
afterStatus := before.Status
counterDelta := 0
switch in.Action {
case "approve":
if before.Status != "reviewing" && before.Status != "rejected" {
return gerror.New("仅待审核或已拒绝评论可审核通过")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "approved"
data[columns.PublishedAt] = now
counterDelta = 1
case "reject":
if before.Status != "reviewing" {
return gerror.New("仅待审核评论可拒绝")
}
afterStatus = "rejected"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "rejected"
data[columns.DeleteReason] = in.Reason
case "take_down":
if before.Status != "published" {
return gerror.New("仅已发布评论可下架")
}
afterStatus = "taken_down"
data[columns.Status] = afterStatus
data[columns.DeleteReason] = in.Reason
counterDelta = -1
case "restore":
if before.Status != "taken_down" {
return gerror.New("仅已下架评论可恢复")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.DeleteReason] = ""
counterDelta = 1
}
if _, err = tx.Model(dao.FeedComment.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新评论审核状态失败")
}
if counterDelta != 0 {
postColumns := dao.FeedPost.Columns()
expression := "`comment_count` + 1"
if counterDelta < 0 {
expression = "GREATEST(`comment_count` - 1, 0)"
}
if _, err = tx.Model(dao.FeedPost.Table()).Where(g.Map{postColumns.Id: before.PostId, postColumns.TenantId: tenantID}).Data(g.Map{postColumns.CommentCount: gdb.Raw(expression), postColumns.UpdatedAt: now}).Update(); err != nil {
return gerror.Wrap(err, "更新动态评论数失败")
}
}
if err = writeContentAudit(ctx, tx, tenantID, operatorID, "feed_comment", before.Id, before.Status, afterStatus, in.Action, now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content."+in.Action, "feed_comment", in.Id, before, data, in.Reason, now)
}
func writeContentAudit(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, objectType string, objectID uint64, before, after, result string, now time.Time) error {
id, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return err
}
columns := dao.ContentAuditLog.Columns()
requestID := fmt.Sprintf("manual-%s-%d-%d", objectType, objectID, id)
_, err = tx.Model(dao.ContentAuditLog.Table()).Data(g.Map{columns.Id: id, columns.TenantId: tenantID, columns.ObjectType: objectType, columns.ObjectId: objectID, columns.AuditRequestId: requestID, columns.AuditSource: "manual", columns.StatusBefore: before, columns.StatusAfter: after, columns.ResultCode: result, columns.RiskLabels: "[]", columns.OperatorId: operatorID, columns.OccurredAt: now, columns.CreatedAt: now}).Insert()
return err
}
func (s *sSysCultivateContent) ReportList(ctx context.Context, in *sysin.CultivateReportListInp) (list []*sysin.CultivateReportListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return nil, 0, err
}
columns := dao.FeedReport.Columns()
mod := dao.FeedReport.Ctx(ctx).Where(columns.TenantId, cultivateTenantID(ctx))
if in.ObjectType != "" {
mod = mod.Where(columns.ObjectType, in.ObjectType)
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
}
if in.ReporterId != "" {
mod = mod.Where(columns.ReporterId, in.ReporterId)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedReport
if err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false); err != nil {
return nil, 0, gerror.Wrap(err, "获取举报列表失败")
}
list = make([]*sysin.CultivateReportListModel, 0, len(rows))
for _, row := range rows {
list = append(list, reportModel(row))
}
return
}
func reportModel(row *entity.FeedReport) *sysin.CultivateReportListModel {
return &sysin.CultivateReportListModel{Id: strconv.FormatUint(row.Id, 10), ReporterId: strconv.FormatUint(row.ReporterId, 10), ReportedUserId: strconv.FormatUint(row.ReportedUserId, 10), ObjectType: row.ObjectType, ObjectId: row.ObjectId, ReasonCode: row.ReasonCode, Description: row.Description, EvidenceFileIds: row.EvidenceFileIds, SourcePage: row.SourcePage, Status: row.Status, HandlerId: optionalUint(row.HandlerId), HandleResult: row.HandleResult, HandledAt: row.HandledAt, CreatedAt: row.CreatedAt}
}
func (s *sSysCultivateContent) ReportView(ctx context.Context, in *sysin.CultivateReportViewInp) (*sysin.CultivateReportViewModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
}
tenantID := cultivateTenantID(ctx)
c := dao.FeedReport.Columns()
var row entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.Id: in.ReportId, c.TenantId: tenantID}).Scan(&row); err != nil {
return nil, err
}
if row.Id == 0 {
return nil, gerror.New("举报记录不存在")
}
out := &sysin.CultivateReportViewModel{CultivateReportListModel: *reportModel(&row), History: []*sysin.CultivateReportListModel{}}
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{out.ReporterId, out.ReportedUserId})
if p := profiles[out.ReporterId]; p != nil {
out.ReporterNickname = p.Nickname
}
if p := profiles[out.ReportedUserId]; p != nil {
out.ReportedUserNickname = p.Nickname
}
var history []*entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.TenantId: tenantID, c.ReportedUserId: row.ReportedUserId}).OrderDesc(c.Id).Limit(20).Scan(&history); err == nil {
for _, item := range history {
out.History = append(out.History, reportModel(item))
}
}
if row.ObjectType == "feed_post" || row.ObjectType == "feed_comment" {
view, err := s.View(ctx, &sysin.CultivateContentViewInp{ObjectType: row.ObjectType, Id: row.ObjectId, ObjectId: mustUint(row.ObjectId)})
if err == nil {
out.TargetContent = &view.CultivateContentListModel
}
}
return out, nil
}
func (s *sSysCultivateContent) ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
columns := dao.FeedReport.Columns()
var report entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).Scan(&report); err != nil {
return err
}
if report.Id == 0 {
return gerror.New("举报记录不存在")
}
if report.Status == "resolved" || report.Status == "rejected" {
return gerror.New("举报已办结,不能重复处理")
}
if in.ContentAction == "take_down" {
if err := validateReportedContentCanTakeDown(ctx, tenantID, &report); err != nil {
return err
}
}
if in.UserAction != "none" && report.ReportedUserId == 0 {
return gerror.New("被举报用户不存在,无法执行用户处置")
}
if in.UserAction != "none" {
if err := governCultivateUser(ctx, tenantID, report.ReportedUserId, in.UserAction, in.Reason); err != nil {
return gerror.Wrap(err, "用户联动处置失败")
}
}
err := dao.FeedReport.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
columns := dao.FeedReport.Columns()
var before entity.FeedReport
err := tx.Model(dao.FeedReport.Table()).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("举报记录不存在")
}
if before.Status == "resolved" || before.Status == "rejected" {
return gerror.New("举报已办结,不能重复处理")
}
now := time.Now().UTC()
status := in.Action
if status == "resolve" {
status = "resolved"
}
data := g.Map{columns.Status: status, columns.HandlerId: operatorID, columns.HandleResult: in.HandleResult, columns.UpdatedAt: now}
if status == "resolved" || status == "rejected" {
data[columns.HandledAt] = now
}
if in.ContentAction == "take_down" {
if err = takeDownReportedContent(txCtx, tx, tenantID, operatorID, &before, in.Reason, now); err != nil {
return err
}
}
if _, err = tx.Model(dao.FeedReport.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新举报处理状态失败")
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, "report."+in.Action, "feed_report", in.Id, before, data, in.HandleResult, now)
})
if err != nil {
return err
}
if in.Action == "resolve" || in.Action == "reject" {
summary := "你的举报已处理,感谢你帮助维护社区环境"
if in.Action == "reject" {
summary = "你的举报已完成核查,暂未发现违规"
}
publishCultivateNotice(ctx, tenantID, report.ReporterId, "admin.report."+in.Id, "cultivate.report.handled", "举报处理结果", summary, map[string]any{"type": "report", "report_id": in.Id})
}
if in.ContentAction != "none" || in.UserAction != "none" {
publishCultivateNotice(ctx, tenantID, report.ReportedUserId, "admin.report.target."+in.Id, "cultivate.governance.applied", "社区治理通知", "你的内容或账号已根据社区规则进行处置", map[string]any{"type": "report", "report_id": in.Id})
}
return nil
}
func (s *sSysCultivateContent) LogList(ctx context.Context, in *sysin.CultivateOperationLogListInp) (list []*sysin.CultivateOperationLogListModel, totalCount int, err error) {
columns := dao.AdminOperationLog.Columns()
mod := dao.AdminOperationLog.Ctx(ctx).Where(columns.TenantId, cultivateTenantID(ctx))
if in.OperatorId != "" {
mod = mod.Where(columns.OperatorId, in.OperatorId)
}
if in.OperationType != "" {
mod = mod.WhereLike(columns.OperationType, "%"+strings.TrimSpace(in.OperationType)+"%")
}
if in.TargetType != "" {
mod = mod.Where(columns.TargetType, in.TargetType)
}
if in.TargetId != "" {
mod = mod.Where(columns.TargetId, strings.TrimSpace(in.TargetId))
}
if in.RequestId != "" {
mod = mod.Where(columns.RequestId, strings.TrimSpace(in.RequestId))
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.AdminOperationLog
if err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false); err != nil {
return nil, 0, gerror.Wrap(err, "获取操作日志失败")
}
list = make([]*sysin.CultivateOperationLogListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateOperationLogListModel{Id: strconv.FormatUint(row.Id, 10), OperatorId: strconv.FormatUint(row.OperatorId, 10), OperationType: row.OperationType, TargetType: row.TargetType, TargetId: row.TargetId, RequestId: row.RequestId, BeforeSnapshot: jsonValue(row.BeforeSnapshot), AfterSnapshot: jsonValue(row.AfterSnapshot), Reason: row.Reason, CreatedAt: row.CreatedAt})
}
return
}
func optionalUint(value uint64) string {
if value == 0 {
return ""
}
return strconv.FormatUint(value, 10)
}
func jsonValue(value *gjson.Json) any {
if value == nil {
return nil
}
return value
}
type cultivateProfile struct {
UserId int64 `json:"user_id"`
Nickname string `json:"nickname"`
AvatarFileId string `json:"avatar_file_id"`
}
func cultivateUserProfiles(ctx context.Context, tenantID int64, userIDs []string) (map[string]*cultivateProfile, error) {
result := make(map[string]*cultivateProfile)
ids := make([]int64, 0, len(userIDs))
for _, value := range userIDs {
id, err := strconv.ParseInt(value, 10, 64)
if err == nil && id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return result, nil
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err != nil {
return nil, err
}
var out struct {
Items []*cultivateProfile `json:"items"`
}
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/users/batch"
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_ids": ids}, &out); err != nil {
return nil, err
}
for _, item := range out.Items {
result[strconv.FormatInt(item.UserId, 10)] = item
}
return result, nil
}
func publishCultivateNotice(ctx context.Context, tenantID int64, targetUserID uint64, eventID, eventType, title, summary string, action map[string]any) {
if targetUserID == 0 {
return
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err == nil {
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/events/publish"
payload := map[string]any{"event_id": eventID, "event_type": eventType, "actor_id": contexts.GetUserId(ctx), "target_user_id": targetUserID, "title": title, "summary": summary, "action": action}
err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
}
if err != nil {
g.Log().Warningf(ctx, "养成通知投递失败: %v", err)
}
}
func cultivateReviewSummary(action string) string {
switch action {
case "approve":
return "你的内容已审核通过"
case "reject":
return "你的内容未通过审核,请修改后重试"
case "take_down":
return "你的内容已根据社区规则下架"
case "restore":
return "你的内容已恢复展示"
default:
return "你的内容审核状态已更新"
}
}
func mustUint(value string) uint64 {
id, _ := strconv.ParseUint(value, 10, 64)
return id
}
func governCultivateUser(ctx context.Context, tenantID int64, userID uint64, action, reason string) error {
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err != nil {
return err
}
eventType := consts.ImAdminCommandUserFreeze
if action == "ban" {
eventType = consts.ImAdminCommandUserBan
}
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatUint(userID, 10) + "/action"
payload := map[string]any{"event_type": eventType, "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), "reason": reason}
return imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
}
func takeDownReportedContent(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, report *entity.FeedReport, reason string, now time.Time) error {
id := mustUint(report.ObjectId)
if id == 0 {
return gerror.New("举报对象ID不正确")
}
if report.ObjectType == "feed_post" {
columns := dao.FeedPost.Columns()
var before entity.FeedPost
if err := tx.Model(dao.FeedPost.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
return err
}
if before.Id == 0 {
return gerror.New("被举报动态不存在")
}
if before.Status != "published" {
return gerror.New("仅已发布动态可联动下架")
}
data := g.Map{columns.Status: "taken_down", columns.TakenDownAt: now, columns.RejectReasonCode: reason, columns.UpdatedAt: now, columns.Version: gdb.Raw(columns.Version + "+1")}
if _, err := tx.Model(dao.FeedPost.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
return err
}
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
}
if report.ObjectType == "feed_comment" {
columns := dao.FeedComment.Columns()
var before entity.FeedComment
if err := tx.Model(dao.FeedComment.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
return err
}
if before.Id == 0 {
return gerror.New("被举报评论不存在")
}
if before.Status != "published" {
return gerror.New("仅已发布评论可联动下架")
}
data := g.Map{columns.Status: "taken_down", columns.DeleteReason: reason, columns.UpdatedAt: now}
if _, err := tx.Model(dao.FeedComment.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
return err
}
postColumns := dao.FeedPost.Columns()
if _, err := tx.Model(dao.FeedPost.Table()).Where(g.Map{postColumns.Id: before.PostId, postColumns.TenantId: tenantID}).Data(g.Map{postColumns.CommentCount: gdb.Raw("GREATEST(`comment_count` - 1, 0)"), postColumns.UpdatedAt: now}).Update(); err != nil {
return err
}
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
}
return gerror.New("该举报对象不支持内容联动下架")
}
func validateReportedContentCanTakeDown(ctx context.Context, tenantID int64, report *entity.FeedReport) error {
id := mustUint(report.ObjectId)
if id == 0 {
return gerror.New("举报对象ID不正确")
}
switch report.ObjectType {
case "feed_post":
columns := dao.FeedPost.Columns()
value, err := dao.FeedPost.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
if err != nil {
return err
}
if value.String() != "published" {
return gerror.New("仅已发布动态可联动下架")
}
case "feed_comment":
columns := dao.FeedComment.Columns()
value, err := dao.FeedComment.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
if err != nil {
return err
}
if value.String() != "published" {
return gerror.New("仅已发布评论可联动下架")
}
default:
return gerror.New("该举报对象不支持内容联动下架")
}
return nil
}
@@ -0,0 +1,259 @@
package sys
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/library/location"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
type sSysCultivateFlower struct{}
func NewSysCultivateFlower() *sSysCultivateFlower { return &sSysCultivateFlower{} }
func init() { service.RegisterSysCultivateFlower(NewSysCultivateFlower()) }
func cultivateTenantID(ctx context.Context) int64 {
id := contexts.GetTenantId(ctx)
if id <= 0 {
id = g.Cfg().MustGet(ctx, "cultivate.defaultTenantId", 1).Int64()
}
return id
}
func (s *sSysCultivateFlower) List(ctx context.Context, in *sysin.CultivateFlowerListInp) (list []*sysin.CultivateFlowerListModel, totalCount int, err error) {
c := dao.FlowerCatalog.Columns()
mod := dao.FlowerCatalog.Ctx(ctx).Fields("cv_flower_catalog.*, COUNT(cv_flower_stage_rule.id) AS stage_count").LeftJoin(dao.FlowerStageRule.Table(), "cv_flower_stage_rule.flower_catalog_id=cv_flower_catalog.id AND cv_flower_stage_rule.tenant_id=cv_flower_catalog.tenant_id").Where("cv_flower_catalog."+c.TenantId, cultivateTenantID(ctx)).Group("cv_flower_catalog." + c.Id)
if in.FlowerCode != "" {
mod = mod.WhereLike("cv_flower_catalog."+c.FlowerCode, "%"+strings.TrimSpace(in.FlowerCode)+"%")
}
if in.FlowerName != "" {
mod = mod.WhereLike("cv_flower_catalog."+c.FlowerName, "%"+strings.TrimSpace(in.FlowerName)+"%")
}
if in.Status != "" {
mod = mod.Where("cv_flower_catalog."+c.Status, in.Status)
}
if in.ConfigVersion != "" {
mod = mod.Where("cv_flower_catalog."+c.ConfigVersion, in.ConfigVersion)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("cv_flower_catalog."+c.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("cv_flower_catalog." + c.SortNo).OrderDesc("cv_flower_catalog." + c.Id)
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取花种列表失败")
}
return
}
func (s *sSysCultivateFlower) View(ctx context.Context, in *sysin.CultivateFlowerViewInp) (res *sysin.CultivateFlowerViewModel, err error) {
c := dao.FlowerCatalog.Columns()
var catalog entity.FlowerCatalog
if err = dao.FlowerCatalog.Ctx(ctx).Where(g.Map{c.Id: in.Id, c.TenantId: cultivateTenantID(ctx)}).Scan(&catalog); err != nil {
return
}
if catalog.Id == 0 {
return nil, gerror.New("花种不存在")
}
res = &sysin.CultivateFlowerViewModel{CultivateFlowerEditInp: sysin.CultivateFlowerEditInp{Id: catalog.Id, FlowerCode: catalog.FlowerCode, FlowerName: catalog.FlowerName, FlowerDesc: catalog.FlowerDesc, IconFileId: catalog.IconFileId, Status: catalog.Status, DailyWaterLimit: catalog.DailyWaterLimit, DailyFertilizeLimit: catalog.DailyFertilizeLimit, ShareConfig: catalog.ShareConfig, SortNo: catalog.SortNo}, ConfigVersion: catalog.ConfigVersion, CreatedAt: catalog.CreatedAt, UpdatedAt: catalog.UpdatedAt}
rc := dao.FlowerStageRule.Columns()
var rules []*entity.FlowerStageRule
if err = dao.FlowerStageRule.Ctx(ctx).Where(g.Map{rc.TenantId: cultivateTenantID(ctx), rc.FlowerCatalogId: catalog.Id}).OrderAsc(rc.SortNo).OrderAsc(rc.Id).Scan(&rules); err != nil {
return
}
for _, rule := range rules {
actions := []string{}
if rule.AllowedActions != nil {
_ = json.Unmarshal(rule.AllowedActions.MustToJson(), &actions)
}
res.StageRules = append(res.StageRules, &sysin.CultivateStageRuleEditInp{Id: rule.Id, Stage: rule.Stage, NextStage: rule.NextStage, RequiredPoints: rule.RequiredPoints, WaterPoints: rule.WaterPoints, FertilizePoints: rule.FertilizePoints, AllowedActions: actions, ImageFileId: rule.ImageFileId, AnimationFileId: rule.AnimationFileId, ProgressTextTemplate: rule.ProgressTextTemplate, CompletionText: rule.CompletionText, SortNo: rule.SortNo})
}
return
}
func (s *sSysCultivateFlower) Edit(ctx context.Context, in *sysin.CultivateFlowerEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return err
}
tenantID := cultivateTenantID(ctx)
if err = validateCultivateAssets(ctx, tenantID, in); err != nil {
return err
}
operatorID := contexts.GetUserId(ctx)
now := time.Now().UTC()
version := "cat_" + now.Format("20060102_150405")
return dao.FlowerCatalog.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
c := dao.FlowerCatalog.Columns()
var before entity.FlowerCatalog
if in.Id > 0 {
if e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).LockUpdate().Scan(&before); e != nil {
return e
}
if before.Id == 0 {
return gerror.New("花种不存在")
}
if before.FlowerCode != in.FlowerCode {
return gerror.New("花种编码创建后不可修改")
}
}
shareJSON := "{}"
if in.ShareConfig != nil {
shareJSON = in.ShareConfig.MustToJsonString()
}
data := g.Map{c.FlowerName: in.FlowerName, c.FlowerDesc: in.FlowerDesc, c.IconFileId: in.IconFileId, c.Status: in.Status, c.ConfigVersion: version, c.DailyWaterLimit: in.DailyWaterLimit, c.DailyFertilizeLimit: in.DailyFertilizeLimit, c.ShareConfig: shareJSON, c.SortNo: in.SortNo, c.UpdatedAt: now}
if in.Status == "online" {
data[c.OnlineAt] = now
data[c.OfflineAt] = nil
} else if in.Status == "offline" {
data[c.OfflineAt] = now
}
if in.Id == 0 {
id, e := imtenant.NextSnowflakeID(txCtx)
if e != nil {
return e
}
in.Id = uint64(id)
data[c.Id] = id
data[c.TenantId] = tenantID
data[c.FlowerCode] = in.FlowerCode
data[c.CreatedAt] = now
if _, e = tx.Model(dao.FlowerCatalog.Table()).Data(data).Insert(); e != nil {
return gerror.Wrap(e, "新增花种失败")
}
} else {
if _, e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).Data(data).Update(); e != nil {
return gerror.Wrap(e, "修改花种失败")
}
}
rc := dao.FlowerStageRule.Columns()
if _, e := tx.Model(dao.FlowerStageRule.Table()).Where(g.Map{rc.TenantId: tenantID, rc.FlowerCatalogId: in.Id}).Delete(); e != nil {
return e
}
for _, rule := range in.StageRules {
id, e := imtenant.NextSnowflakeID(txCtx)
if e != nil {
return e
}
actions, _ := json.Marshal(rule.AllowedActions)
_, e = tx.Model(dao.FlowerStageRule.Table()).Data(g.Map{rc.Id: id, rc.TenantId: tenantID, rc.FlowerCatalogId: in.Id, rc.Stage: rule.Stage, rc.NextStage: rule.NextStage, rc.RequiredPoints: rule.RequiredPoints, rc.WaterPoints: rule.WaterPoints, rc.FertilizePoints: rule.FertilizePoints, rc.AllowedActions: string(actions), rc.ImageFileId: rule.ImageFileId, rc.AnimationFileId: rule.AnimationFileId, rc.ProgressTextTemplate: rule.ProgressTextTemplate, rc.CompletionText: rule.CompletionText, rc.SortNo: rule.SortNo, rc.CreatedAt: now, rc.UpdatedAt: now}).Insert()
if e != nil {
return gerror.Wrap(e, "保存成长阶段失败")
}
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, map[bool]string{true: "flower.edit", false: "flower.create"}[before.Id > 0], "flower", fmt.Sprint(in.Id), before, in, "", now)
})
}
func validateCultivateAssets(ctx context.Context, tenantID int64, in *sysin.CultivateFlowerEditInp) error {
seen := make(map[string]struct{})
fileIDs := make([]string, 0, 1+len(in.StageRules)*2)
add := func(fileID string) {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return
}
if _, ok := seen[fileID]; ok {
return
}
seen[fileID] = struct{}{}
fileIDs = append(fileIDs, fileID)
}
add(in.IconFileId)
for _, rule := range in.StageRules {
add(rule.ImageFileId)
add(rule.AnimationFileId)
}
if len(fileIDs) == 0 {
return nil
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err != nil {
return err
}
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/files/validate"
for offset := 0; offset < len(fileIDs); offset += 20 {
end := offset + 20
if end > len(fileIDs) {
end = len(fileIDs)
}
var out struct {
Items []struct {
FileId string `json:"file_id"`
ReviewStatus string `json:"review_status"`
} `json:"items"`
}
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_id": 0, "file_ids": fileIDs[offset:end]}, &out); err != nil {
return gerror.Wrap(err, "校验花种素材失败")
}
for _, item := range out.Items {
if item.ReviewStatus == "risky" {
return gerror.Newf("素材 %s 审核未通过", item.FileId)
}
if item.ReviewStatus == "pending" {
return gerror.Newf("素材 %s 尚在审核中", item.FileId)
}
}
}
return nil
}
func (s *sSysCultivateFlower) Status(ctx context.Context, in *sysin.CultivateFlowerStatusInp) error {
tenantID := cultivateTenantID(ctx)
operatorID := contexts.GetUserId(ctx)
now := time.Now().UTC()
return dao.FlowerCatalog.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
c := dao.FlowerCatalog.Columns()
var before entity.FlowerCatalog
if e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).LockUpdate().Scan(&before); e != nil {
return e
}
if before.Id == 0 {
return gerror.New("花种不存在")
}
data := g.Map{c.Status: in.Status, c.ConfigVersion: "cat_" + now.Format("20060102_150405"), c.UpdatedAt: now}
if in.Status == "online" {
data[c.OnlineAt] = now
data[c.OfflineAt] = nil
} else if in.Status == "offline" {
data[c.OfflineAt] = now
}
if _, e := tx.Model(dao.FlowerCatalog.Table()).Where(c.Id, in.Id).Data(data).Update(); e != nil {
return e
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, "flower.status", "flower", fmt.Sprint(in.Id), before, data, in.Reason, now)
})
}
func writeCultivateOperation(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, operation, targetType, targetID string, before, after any, reason string, now time.Time) error {
id, e := imtenant.NextSnowflakeID(ctx)
if e != nil {
return e
}
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
ipAddress, userAgent := "", ""
if request := ghttp.RequestFromCtx(ctx); request != nil {
ipAddress = location.GetClientIp(request)
userAgent = request.UserAgent()
}
c := dao.AdminOperationLog.Columns()
_, e = tx.Model(dao.AdminOperationLog.Table()).Data(g.Map{c.Id: id, c.TenantId: tenantID, c.OperatorId: operatorID, c.OperationType: operation, c.TargetType: targetType, c.TargetId: targetID, c.RequestId: gctx.CtxId(ctx), c.BeforeSnapshot: gjson.New(beforeJSON).MustToJsonString(), c.AfterSnapshot: gjson.New(afterJSON).MustToJsonString(), c.Reason: reason, c.IpAddress: ipAddress, c.UserAgent: userAgent, c.CreatedAt: now}).Insert()
return e
}
+46 -1
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,11 +31,18 @@ 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 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
_, err := dao.SysDictData.Ctx(ctx).Where("id", in.Id).Delete()
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, oldTypes)
return nil
}
@@ -42,11 +50,18 @@ func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp)
func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err error) {
// 修改
if in.Id > 0 {
if count, queryErr := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); queryErr != nil {
return gerror.Wrap(queryErr, consts.ErrorORM)
} 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
}
@@ -60,18 +75,45 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err
if in.Type == "" {
return gerror.Wrap(err, "类型选择无效,请检查")
}
if in.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
_, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataInsertFields{}).Data(in).OmitEmptyData().Insert()
if err != nil {
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)
mod := dao.SysDictData.Ctx(ctx).WhereNot(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType)
// 类型ID
if in.TypeID > 0 {
types, err := s.GetTypes(ctx, in.TypeID)
@@ -151,6 +193,9 @@ func (s *sSysDictData) GetTypes(ctx context.Context, id int64) (types []string,
// Select 获取列表
func (s *sSysDictData) Select(ctx context.Context, in *sysin.DataSelectInp) (list sysin.DataSelectModel, err error) {
if in.Type == consts.ImInternalSecretDictType {
return nil, gerror.New("内部密钥字典禁止通过通用接口读取")
}
// 优先从内置字典中获取
options, err := dict.GetOptions(ctx, in.Type)
if err == nil {
+17 -2
View File
@@ -31,7 +31,7 @@ func init() {
// Tree 树
func (s *sSysDictType) Tree(ctx context.Context) (list []*sysin.DictTypeTree, err error) {
var (
mod = dao.SysDictType.Ctx(ctx)
mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType)
models []*entity.SysDictType
)
@@ -55,6 +55,9 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp)
err = gerror.New("数据不存在或已删除!")
return
}
if models.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
exist, err := dao.SysDictData.Ctx(ctx).Where("type", models.Type).One()
if err != nil {
@@ -83,6 +86,18 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp)
// Edit 修改/新增
func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err error) {
if in.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
if in.Id > 0 {
count, queryErr := dao.SysDictType.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType).Count()
if queryErr != nil {
return gerror.Wrap(queryErr, consts.ErrorORM)
}
if count > 0 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
}
if err = hgorm.IsUnique(ctx, &dao.SysDictType, g.Map{dao.SysDictType.Columns().Name: in.Name}, "名称已存在", in.Id); err != nil {
return
}
@@ -105,7 +120,7 @@ func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err
// TreeSelect 获取类型关系树选项
func (s *sSysDictType) TreeSelect(ctx context.Context, in *sysin.DictTreeSelectInp) (list []*sysin.DictTypeTree, err error) {
var (
mod = dao.SysDictType.Ctx(ctx)
mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType)
models []*entity.SysDictType
)
+60 -6
View File
@@ -5,7 +5,6 @@ import (
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/service"
@@ -18,19 +17,34 @@ import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"strings"
)
type imAdminScope struct {
TenantId int64
OperatorId int64
RoleKey string
RequestId string
}
const (
imAdminOperationAuditTable = "im_admin_operation_audit"
)
func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imadmin.AdminScope, err error) {
func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imAdminScope, err error) {
return resolveImAdminScopeWithAll(ctx, selectedTenantId, false)
}
func resolveImAdminScopeWithAll(ctx context.Context, selectedTenantId int64, allowAll bool) (scope imAdminScope, err error) {
operatorId := contexts.GetUserId(ctx)
scope.OperatorId = operatorId
scope.RoleKey = contexts.GetRoleKey(ctx)
scope.RequestId = gctx.CtxId(ctx)
if isImPlatformAdmin(ctx, operatorId) {
if selectedTenantId <= 0 {
if allowAll {
return scope, nil
}
return scope, gerror.New("请选择IM租户")
}
if err = ensureImTenantActive(ctx, selectedTenantId); err != nil {
@@ -40,7 +54,7 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima
return scope, nil
}
if !contexts.IsTenantDept(ctx) {
if !contexts.IsTenantDept(ctx) && !(contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) > 0) {
return scope, gerror.New("当前账号无权管理IM租户")
}
tenantId, err := resolveImAdminBoundTenant(ctx)
@@ -55,10 +69,13 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima
}
func isImPlatformAdmin(ctx context.Context, operatorId int64) bool {
if contexts.IsCompanyDept(ctx) {
if service.AdminMember().VerifySuperId(ctx, operatorId) {
return true
}
return service.AdminMember().VerifySuperId(ctx, operatorId)
if contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey {
return true
}
return contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) <= 0
}
func resolveImAdminBoundTenant(ctx context.Context) (int64, error) {
@@ -69,9 +86,46 @@ func resolveImAdminBoundTenant(ctx context.Context) (int64, error) {
return tenantId, nil
}
// ensureImRecordTenantAllowed 校验目标记录/租户是否在当前操作者允许范围内。
// 平台管理员(超管/平台角色/未绑定租户的公司部门)可访问任意租户的记录;
// 其余账号强制限定为自己绑定的租户(与 resolveImAdminScope 的范围逻辑一致)。
// 适用于入参不含租户ID、需按已查出的记录租户做后置校验的场景。
func ensureImRecordTenantAllowed(ctx context.Context, recordTenantId int64) error {
if isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return nil
}
if !contexts.IsTenantDept(ctx) && !(contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) > 0) {
return gerror.New("当前账号无权管理IM租户")
}
boundTenantId, err := resolveImAdminBoundTenant(ctx)
if err != nil {
return err
}
if boundTenantId != recordTenantId {
return gerror.New("无权访问其他商户的IM数据")
}
return nil
}
// lookupImTenantByCode 按租户编码查询租户(用于入参只有租户编码的管理端接口)
func lookupImTenantByCode(ctx context.Context, code string) (tenant *entity.ImTenant, err error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, gerror.New("请选择IM租户")
}
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().Code, code).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
return nil, gerror.New("IM租户不存在")
}
return tenant, nil
}
func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) {
var tenant *entity.ImTenant
if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
@@ -5,15 +5,25 @@ import (
"testing"
"hotgo/internal/consts"
_ "hotgo/internal/logic/admin"
"hotgo/internal/model"
)
func TestCompanyDepartmentIsImPlatformAdmin(t *testing.T) {
func TestBoundCompanyDepartmentUsesBoundTenant(t *testing.T) {
ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{
User: &model.Identity{Id: 14, TenantId: 1, DeptType: consts.DeptTypeCompany, RoleKey: "imManage"},
})
if isImPlatformAdmin(ctx, 14) {
t.Fatal("expected bound company department IM manager to use its bound tenant")
}
}
func TestUnboundCompanyDepartmentIsImPlatformAdmin(t *testing.T) {
ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{
User: &model.Identity{Id: 14, DeptType: consts.DeptTypeCompany, RoleKey: "imManage"},
})
if !isImPlatformAdmin(ctx, 14) {
t.Fatal("expected company department IM manager to use platform tenant selection")
t.Fatal("expected unbound company department IM manager to use platform tenant selection")
}
}
@@ -31,6 +41,14 @@ func TestResolveImAdminBoundTenant(t *testing.T) {
},
want: 2,
},
{
name: "company department with tenant binding",
identity: &model.Identity{
TenantId: 1,
DeptType: consts.DeptTypeCompany,
},
want: 1,
},
}
for _, tt := range tests {
+3 -24
View File
@@ -10,18 +10,13 @@ import (
"encoding/json"
"fmt"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imkafka"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"net/url"
"strconv"
"github.com/gogf/gf/v2/errors/gerror"
)
type sSysImDeadLetter struct{}
@@ -39,7 +34,7 @@ func (s *sSysImDeadLetter) List(ctx context.Context, in *sysin.ImDeadLetterListI
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -81,7 +76,7 @@ func (s *sSysImDeadLetter) View(ctx context.Context, in *sysin.ImDeadLetterViewI
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -120,7 +115,7 @@ func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.I
if err := in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -162,22 +157,6 @@ func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.I
}, nil
}
func (s *sSysImDeadLetter) getTenant(ctx context.Context, tenantId int64) (tenant *entity.ImTenant, err error) {
if tenantId <= 0 {
return nil, gerror.New("请选择IM租户")
}
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
return nil, gerror.New("IM租户不存在")
}
if tenant.State != consts.ImTenantStateActive {
return nil, gerror.New("IM租户未启用")
}
return tenant, nil
}
func convertDeadLetterModel(in *sysin.ImDeadLetterListModelRaw) *sysin.ImDeadLetterListModel {
if in == nil {
return &sysin.ImDeadLetterListModel{}
@@ -5,16 +5,22 @@ import (
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/immoderation"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"strings"
"time"
)
type sSysImExternalConfig struct{}
@@ -36,7 +42,13 @@ func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, 0, gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
mod := s.model(ctx).Where("tenant_id", tenant.Id)
if tenant.Code != "default" {
mod = mod.WhereNot("service", consts.ImExternalServiceModeration)
}
if in.Service != "" {
mod = mod.Where("service", in.Service)
}
@@ -68,6 +80,9 @@ func (s *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return nil, err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, gerror.New("第一阶段仅默认商户可查看内容审核服务")
}
if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!")
}
@@ -91,6 +106,34 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以维护内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration {
if in.Provider != "aliyun_green" {
return gerror.New("内容审核服务商必须为aliyun_green")
}
in.Endpoint = ""
if in.ConfigJson == nil {
in.ConfigJson = gjson.New(map[string]any{"nickname_service": "nickname_detection_pro", "chat_service": "chat_detection_pro", "timeout_ms": 1500, "failure_mode": "allow"})
}
config := in.ConfigJson.Map()
if timeout := gconv.Int(config["timeout_ms"]); timeout < 200 || timeout > 10000 {
return gerror.New("内容审核超时必须为200至10000毫秒")
}
if mode := gconv.String(config["failure_mode"]); mode != "allow" && mode != "block" {
return gerror.New("内容审核异常策略不合法")
}
in.ConfigJson = gjson.New(map[string]any{
"nickname_service": firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"),
"chat_service": firstExternalValue(gconv.String(config["chat_service"]), "chat_detection_pro"),
"timeout_ms": gconv.Int(config["timeout_ms"]),
"failure_mode": gconv.String(config["failure_mode"]),
})
}
configJson := "{}"
if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" {
configJson = in.ConfigJson.String()
@@ -106,6 +149,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" {
data["secret_json"] = in.SecretJson.String()
}
if in.Service == consts.ImExternalServiceModeration {
data["secret_json"] = "{}"
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新IM外部服务配置失败,请稍后重试!")
@@ -113,6 +159,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
_ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{
"service": in.Service, "provider": in.Provider, "enabled": in.Enabled,
})
if in.Service == consts.ImExternalServiceModeration {
_ = publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "third_party.updated", "moderation", 0)
}
return nil
}
@@ -127,6 +176,12 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可检查内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以检查内容审核服务")
}
var row gdb.Record
if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil {
@@ -137,6 +192,8 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" {
status = consts.ImExternalConfigStatusUnconfigured
result = "未启用或服务商为空"
} else if in.Service == consts.ImExternalServiceModeration {
status, result = s.checkModeration(ctx, row)
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).
@@ -150,6 +207,45 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
return nil
}
func (s *sSysImExternalConfig) checkModeration(ctx context.Context, row gdb.Record) (string, string) {
if row["provider"].String() != "aliyun_green" {
return consts.ImExternalConfigStatusUnconfigured, "内容审核服务商必须为aliyun_green"
}
config := map[string]any{}
_ = json.Unmarshal([]byte(row["config_json"].String()), &config)
globalConfig, err := immoderation.LoadAliyunGlobalConfig(ctx)
if err != nil {
return consts.ImExternalConfigStatusUnconfigured, sanitizeExternalError(err)
}
timeoutMS := gconv.Int(config["timeout_ms"])
if timeoutMS <= 0 {
timeoutMS = 1500
}
check, err := immoderation.CheckAliyunText(ctx, immoderation.AliyunConfig{
AccessKeyID: globalConfig.AccessKeyID, AccessKeySecret: globalConfig.AccessKeySecret,
RegionID: globalConfig.RegionID, Endpoint: globalConfig.Endpoint,
Service: firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"), Timeout: time.Duration(timeoutMS) * time.Millisecond,
})
if err != nil {
return consts.ImExternalConfigStatusUnconfigured, "连通性检查失败:" + sanitizeExternalError(err)
}
return consts.ImExternalConfigStatusConfigured, fmt.Sprintf("检查成功,耗时%dms,RequestID=%s", check.Latency.Milliseconds(), check.RequestID)
}
func firstExternalValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func sanitizeExternalError(err error) string {
value := strings.ReplaceAll(err.Error(), "\n", " ")
if len([]rune(value)) > 120 {
value = string([]rune(value)[:120])
}
return value
}
func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model {
return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields(
"tenant_id",
@@ -2,17 +2,13 @@ package sys
import (
"context"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imFeedbackConfigTable = "im_feedback_config"
type sSysImFeedbackConfig struct{}
func NewSysImFeedbackConfig() *sSysImFeedbackConfig { return &sSysImFeedbackConfig{} }
@@ -25,16 +21,14 @@ func (s *sSysImFeedbackConfig) Get(ctx context.Context, in *sysin.ImFeedbackConf
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.ensureDefault(ctx, db); err != nil {
res = &sysin.ImFeedbackConfigModel{}
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/feedback-config", nil, res); err != nil {
return nil, err
}
if err = s.model(ctx, db).OrderAsc("id").Limit(1).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM意见反馈配置失败,请稍后重试!")
}
if res != nil {
res.TenantId = tenant.Id
res.TenantName = tenant.Name
@@ -46,13 +40,10 @@ func (s *sSysImFeedbackConfig) Update(ctx context.Context, in *sysin.ImFeedbackC
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return err
}
if err = s.ensureDefault(ctx, db); err != nil {
return err
}
categories := `["bug","suggestion","complaint","other"]`
if in.Categories != nil && in.Categories.String() != "" && in.Categories.String() != "null" {
categories = in.Categories.String()
@@ -61,52 +52,15 @@ func (s *sSysImFeedbackConfig) Update(ctx context.Context, in *sysin.ImFeedbackC
if in.AttachmentPolicy != nil && in.AttachmentPolicy.String() != "" && in.AttachmentPolicy.String() != "null" {
attachmentPolicy = in.AttachmentPolicy.String()
}
if _, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Where("id", 1).Data(g.Map{
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/feedback-config", g.Map{
"categories": categories,
"max_length": in.MaxLength,
"attachment_policy": attachmentPolicy,
"allow_contact": in.AllowContact,
"diagnostics_notice": in.DiagnosticsNotice,
"updated_at": gtime.Now(),
}).Update(); err != nil {
return gerror.Wrap(err, "更新IM意见反馈配置失败,请稍后重试!")
}, nil); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_feedback_config_update", "im_feedback_config", 1, "success", g.Map{"allow_contact": in.AllowContact})
return nil
}
func (s *sSysImFeedbackConfig) model(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Fields(
"id",
"CAST(categories AS CHAR) categories",
"max_length",
"CAST(attachment_policy AS CHAR) attachment_policy",
"allow_contact",
"diagnostics_notice",
"updated_at",
)
}
func (s *sSysImFeedbackConfig) ensureDefault(ctx context.Context, db gdb.DB) (err error) {
var count int
if count, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Where("id", 1).Count(); err != nil {
return gerror.Wrap(err, "检查IM意见反馈默认配置失败,请稍后重试!")
}
if count > 0 {
return nil
}
_, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Data(g.Map{
"id": 1,
"categories": `["bug","suggestion","complaint","other"]`,
"max_length": 1000,
"attachment_policy": `{"max_files":5,"purpose":"feedback"}`,
"allow_contact": 1,
"diagnostics_notice": "仅在你同意时上传脱敏诊断信息。",
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert()
if err != nil {
return gerror.Wrap(err, "初始化IM意见反馈默认配置失败,请稍后重试!")
}
return nil
}
+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
})
+29 -8
View File
@@ -33,7 +33,12 @@ func (s *sSysImKafkaCommand) List(ctx context.Context, in *sysin.ImKafkaCommandL
if err = in.Filter(ctx); err != nil {
return
}
where, args := buildImKafkaCommandWhere(in)
// 强制租户范围:商户绑定账号忽略前端所选租户,平台管理员使用所选租户
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return
}
where, args := buildImKafkaCommandWhere(in, scope.TenantId)
db := imtenant.RegistryDB()
totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_admin_commands "+where, args...)
if err != nil {
@@ -91,16 +96,16 @@ func (s *sSysImKafkaCommand) View(ctx context.Context, in *sysin.ImKafkaCommandV
if err = row.Struct(&res); err != nil {
return nil, gerror.Wrap(err, "解析IM后台命令详情失败,请稍后重试!")
}
// 后置校验记录归属租户是否在操作者允许范围内
if err = ensureImRecordTenantAllowed(ctx, res.TenantId); err != nil {
return nil, err
}
return
}
func buildImKafkaCommandWhere(in *sysin.ImKafkaCommandListInp) (string, []any) {
parts := []string{"1=1"}
args := []any{}
if in.TenantId > 0 {
parts = append(parts, "tenant_id=?")
args = append(args, in.TenantId)
}
func buildImKafkaCommandWhere(in *sysin.ImKafkaCommandListInp, scopeTenantId int64) (string, []any) {
parts := []string{"tenant_id=?"}
args := []any{scopeTenantId}
if in.CommandId != "" {
parts = append(parts, "command_id LIKE ?")
args = append(args, "%"+in.CommandId+"%")
@@ -136,6 +141,14 @@ func (s *sSysImKafkaCommand) CacheInvalidate(ctx context.Context, in *sysin.ImKa
if err = in.Filter(ctx); err != nil {
return nil, err
}
// 校验目标租户是否在操作者允许范围内
tenant, err := lookupImTenantByCode(ctx, in.TenantCode)
if err != nil {
return nil, err
}
if err = ensureImRecordTenantAllowed(ctx, tenant.Id); err != nil {
return nil, err
}
user := contexts.GetUser(ctx)
actor := &imkafka.Actor{
Type: "admin",
@@ -170,6 +183,14 @@ func (s *sSysImKafkaCommand) WorkbenchEvent(ctx context.Context, in *sysin.ImKaf
if err = in.Filter(ctx); err != nil {
return nil, err
}
// 校验目标租户是否在操作者允许范围内
tenant, err := lookupImTenantByCode(ctx, in.TenantCode)
if err != nil {
return nil, err
}
if err = ensureImRecordTenantAllowed(ctx, tenant.Id); err != nil {
return nil, err
}
user := contexts.GetUser(ctx)
actor := &imkafka.Actor{
Type: "admin",
@@ -5,6 +5,7 @@ import (
"fmt"
"hotgo/internal/consts"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imkafka"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
@@ -15,6 +16,7 @@ import (
"strconv"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
@@ -128,6 +130,26 @@ func (s *sSysImLoginFailureLog) AddBlacklist(ctx context.Context, in *sysin.ImLo
})
}
func (s *sSysImLoginFailureLog) Unlock(ctx context.Context, in *sysin.ImLoginFailureUnlockInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return err
}
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/login-lockouts/release"
var out struct {
AccountHash string `json:"account_hash"`
Status string `json:"status"`
}
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]string{"account_hash": in.AccountHash}, &out); err != nil {
return err
}
g.Log().Infof(ctx, "登录锁定已解锁 tenantId:%d accountHash:%s adminId:%d status:%s", tenant.Id, in.AccountHash, contexts.GetUserId(ctx), out.Status)
return nil
}
func (s *sSysImLoginFailureLog) BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (*sysin.ImUserCommandModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
+8 -61
View File
@@ -2,17 +2,13 @@ package sys
import (
"context"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imMailboxConfigTable = "im_mailbox_config"
type sSysImMailboxConfig struct{}
func NewSysImMailboxConfig() *sSysImMailboxConfig { return &sSysImMailboxConfig{} }
@@ -25,16 +21,14 @@ func (s *sSysImMailboxConfig) Get(ctx context.Context, in *sysin.ImMailboxConfig
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.ensureDefault(ctx, db); err != nil {
res = &sysin.ImMailboxConfigModel{}
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/mailbox-config", nil, res); err != nil {
return nil, err
}
if err = s.model(ctx, db).Where("id", 1).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM总经理信箱配置失败,请稍后重试!")
}
if res != nil {
res.TenantId = tenant.Id
res.TenantName = tenant.Name
@@ -46,13 +40,10 @@ func (s *sSysImMailboxConfig) Update(ctx context.Context, in *sysin.ImMailboxCon
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return err
}
if err = s.ensureDefault(ctx, db); err != nil {
return err
}
requiredFields := `["subject","content"]`
if in.RequiredFields != nil && in.RequiredFields.String() != "" && in.RequiredFields.String() != "null" {
requiredFields = in.RequiredFields.String()
@@ -61,7 +52,7 @@ func (s *sSysImMailboxConfig) Update(ctx context.Context, in *sysin.ImMailboxCon
if in.AttachmentPolicy != nil && in.AttachmentPolicy.String() != "" && in.AttachmentPolicy.String() != "null" {
attachmentPolicy = in.AttachmentPolicy.String()
}
if _, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Where("id", 1).Data(g.Map{
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/mailbox-config", g.Map{
"enabled": in.Enabled,
"allow_anonymous": in.AllowAnonymous,
"required_fields": requiredFields,
@@ -69,53 +60,9 @@ func (s *sSysImMailboxConfig) Update(ctx context.Context, in *sysin.ImMailboxCon
"attachment_policy": attachmentPolicy,
"notice_title": in.NoticeTitle,
"notice_content": in.NoticeContent,
"notice_updated_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Update(); err != nil {
return gerror.Wrap(err, "更新IM总经理信箱配置失败,请稍后重试!")
}, nil); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_mailbox_config_update", "im_mailbox_config", 1, "success", g.Map{"enabled": in.Enabled})
return nil
}
func (s *sSysImMailboxConfig) model(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Fields(
"id",
"enabled",
"allow_anonymous",
"CAST(required_fields AS CHAR) required_fields",
"max_length",
"CAST(attachment_policy AS CHAR) attachment_policy",
"notice_title",
"notice_content",
"notice_updated_at",
"updated_at",
)
}
func (s *sSysImMailboxConfig) ensureDefault(ctx context.Context, db gdb.DB) (err error) {
var count int
if count, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Where("id", 1).Count(); err != nil {
return gerror.Wrap(err, "检查IM总经理信箱默认配置失败,请稍后重试!")
}
if count > 0 {
return nil
}
_, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Data(g.Map{
"id": 1,
"enabled": 1,
"allow_anonymous": 1,
"required_fields": `["subject","content"]`,
"max_length": 5000,
"attachment_policy": `{"max_files":5,"purpose":"executive_mailbox"}`,
"notice_title": "总经理信箱",
"notice_content": "请如实填写诉求,匿名提交将按企业政策保护展示身份。",
"notice_updated_at": gtime.Now(),
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert()
if err != nil {
return gerror.Wrap(err, "初始化IM总经理信箱默认配置失败,请稍后重试!")
}
return nil
}
@@ -0,0 +1,298 @@
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/immessagecrypto"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const (
imMessageEncryptionKeyTable = "im_message_encryption_keys"
imTenantMessageEncryptionColumn = "message_encryption_enabled"
)
type sSysImMessageEncryption struct{}
func NewSysImMessageEncryption() *sSysImMessageEncryption { return &sSysImMessageEncryption{} }
func init() {
service.RegisterSysImMessageEncryption(NewSysImMessageEncryption())
}
func (s *sSysImMessageEncryption) List(ctx context.Context, in *sysin.ImMessageEncryptionListInp) (list []*sysin.ImMessageEncryptionListModel, total int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
if err = s.requireSuperAdmin(ctx); err != nil {
return nil, 0, err
}
db := imtenant.RegistryDB()
mod := db.Model(dao.ImTenant.Table()+" t").Safe().Ctx(ctx).
LeftJoin(dao.ImTenantConfig.Table()+" c", "c.tenant_id=t.id").
Fields(
"t.id tenant_id", "t.code tenant_code", "t.name tenant_name",
"COALESCE(c."+imTenantMessageEncryptionColumn+",0) message_encryption_enabled",
"COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='active'),0) active_version",
"COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='pending'),0) pending_version",
"(SELECT COUNT(1) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) key_count",
"(SELECT MAX(COALESCE(k.activated_at,k.created_at)) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) last_rotated_at",
)
if in.TenantId > 0 {
mod = mod.Where("t.id", in.TenantId)
}
if keyword := strings.TrimSpace(in.Keyword); keyword != "" {
mod = mod.Where("t.code LIKE ? OR t.name LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
if in.Enabled != nil {
mod = mod.Where("COALESCE(c."+imTenantMessageEncryptionColumn+",0)=?", *in.Enabled)
}
if err = mod.Page(in.Page, in.PerPage).OrderAsc("t.id").ScanAndCount(&list, &total, false); err != nil {
return nil, 0, gerror.Wrap(err, "获取商户消息加密配置失败")
}
return
}
func (s *sSysImMessageEncryption) Initialize(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
if err := s.requireSuperAdmin(ctx); err != nil {
return err
}
tenant, err := s.tenantByID(ctx, in.TenantId)
if err != nil {
return err
}
dek, err := immessagecrypto.GenerateDEK()
if err != nil {
return err
}
ciphertext, nonce, err := immessagecrypto.WrapDEK(ctx, tenant.Id, 1, dek)
if err != nil {
return err
}
err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
count, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id).Count()
if queryErr != nil {
return gerror.Wrap(queryErr, "检查商户消息密钥失败")
}
if count > 0 {
return gerror.New("该商户已经初始化消息密钥")
}
_, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{
"tenant_id": tenant.Id, "key_version": 1, "algorithm": consts.ImMessageKeyAlgorithm,
"encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusActive,
"created_at": gtime.Now(), "activated_at": gtime.Now(), "created_by": contexts.GetUserId(ctx),
"remark": strings.TrimSpace(in.Remark),
}).Insert()
if insertErr != nil {
return gerror.Wrap(insertErr, "初始化商户消息密钥失败")
}
return nil
})
if err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "message_key_initialize", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": 1})
return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", 1)
}
func (s *sSysImMessageEncryption) Rotate(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
if err := s.requireSuperAdmin(ctx); err != nil {
return err
}
tenant, err := s.tenantByID(ctx, in.TenantId)
if err != nil {
return err
}
var version uint
err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
pending, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusPending).Count()
if queryErr != nil {
return gerror.Wrap(queryErr, "检查待激活消息密钥失败")
}
if pending > 0 {
return gerror.New("该商户已有待激活密钥,请等待IM Server确认")
}
value, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Fields("COALESCE(MAX(key_version),0)").Where("tenant_id", tenant.Id).Value()
if queryErr != nil {
return gerror.Wrap(queryErr, "获取消息密钥版本失败")
}
if value.Uint() == 0 {
return gerror.New("请先初始化商户消息密钥")
}
version = value.Uint() + 1
dek, generateErr := immessagecrypto.GenerateDEK()
if generateErr != nil {
return generateErr
}
ciphertext, nonce, wrapErr := immessagecrypto.WrapDEK(ctx, tenant.Id, version, dek)
if wrapErr != nil {
return wrapErr
}
_, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{
"tenant_id": tenant.Id, "key_version": version, "algorithm": consts.ImMessageKeyAlgorithm,
"encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusPending,
"created_at": gtime.Now(), "created_by": contexts.GetUserId(ctx), "remark": strings.TrimSpace(in.Remark),
}).Insert()
if insertErr != nil {
return gerror.Wrap(insertErr, "创建待激活消息密钥失败")
}
return nil
})
if err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "message_key_rotate", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": version, "status": consts.ImMessageKeyStatusPending})
return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", version)
}
func (s *sSysImMessageEncryption) Switch(ctx context.Context, in *sysin.ImMessageEncryptionSwitchInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
if err := s.requireSuperAdmin(ctx); err != nil {
return err
}
tenant, err := s.tenantByID(ctx, in.TenantId)
if err != nil {
return err
}
if in.Enabled == 1 {
count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive).Count()
if queryErr != nil {
return gerror.Wrap(queryErr, "检查商户消息密钥失败")
}
if count == 0 {
return gerror.New("启用消息加密前必须先初始化有效密钥")
}
}
result, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).
Data(g.Map{imTenantMessageEncryptionColumn: in.Enabled, dao.ImTenantConfig.Columns().UpdatedBy: contexts.GetUserId(ctx)}).Update()
if err != nil {
return gerror.Wrap(err, "更新商户消息加密开关失败")
}
if affected, _ := result.RowsAffected(); affected == 0 {
return gerror.New("商户配置不存在")
}
_ = writeImAdminAudit(ctx, tenant.Id, "message_encryption_switch", "im_tenant_config", tenant.Id, "success", g.Map{"enabled": in.Enabled})
return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "tenant.features.updated", "message_encryption", 0)
}
func (s *sSysImMessageEncryption) GetKey(ctx context.Context, in *sysin.ImMessageKeyGetInp) (*sysin.ImMessageKeyModel, error) {
tenant, err := s.tenantByCode(ctx, in.TenantCode)
if err != nil {
return nil, err
}
enabledValue, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields(imTenantMessageEncryptionColumn).Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Value()
if err != nil {
return nil, gerror.Wrap(err, "获取商户消息加密开关失败")
}
mod := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id)
if in.Version > 0 {
mod = mod.Where("key_version", in.Version).WhereIn("status", []string{consts.ImMessageKeyStatusPending, consts.ImMessageKeyStatusActive, consts.ImMessageKeyStatusRetired})
} else {
mod = mod.Where("status", consts.ImMessageKeyStatusActive).OrderDesc("key_version")
}
row, err := mod.One()
if err != nil {
return nil, gerror.Wrap(err, "获取商户消息密钥失败")
}
if row.IsEmpty() {
return nil, gerror.New("商户消息密钥不存在")
}
version := row["key_version"].Uint()
dek, err := immessagecrypto.UnwrapDEK(ctx, tenant.Id, version, row["encrypted_dek"].Bytes(), row["dek_nonce"].Bytes())
if err != nil {
return nil, err
}
return &sysin.ImMessageKeyModel{
TenantId: tenant.Id, TenantCode: tenant.Code, Enabled: enabledValue.Int() == 1,
Version: version, Algorithm: row["algorithm"].String(), Status: row["status"].String(), Key: immessagecrypto.EncodeDEK(dek),
}, nil
}
func (s *sSysImMessageEncryption) Ack(ctx context.Context, in *sysin.ImMessageKeyAckInp) error {
tenant, err := s.tenantByCode(ctx, in.TenantCode)
if err != nil {
return err
}
if in.Version == 0 {
return gerror.New("消息密钥版本不能为空")
}
err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
row, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("key_version", in.Version).One()
if queryErr != nil {
return gerror.Wrap(queryErr, "获取待激活消息密钥失败")
}
if row.IsEmpty() || row["status"].String() != consts.ImMessageKeyStatusPending {
return gerror.New("待激活消息密钥不存在")
}
if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive).
Data(g.Map{"status": consts.ImMessageKeyStatusRetired, "retired_at": gtime.Now()}).Update(); updateErr != nil {
return gerror.Wrap(updateErr, "退役旧消息密钥失败")
}
if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("key_version", in.Version).
Data(g.Map{"status": consts.ImMessageKeyStatusActive, "activated_at": gtime.Now()}).Update(); updateErr != nil {
return gerror.Wrap(updateErr, "激活新消息密钥失败")
}
return nil
})
if err != nil {
return err
}
return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.activated", "message_encryption", in.Version)
}
func (s *sSysImMessageEncryption) requireSuperAdmin(ctx context.Context) error {
if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有最高权限平台管理员可以管理消息密钥")
}
return nil
}
func (s *sSysImMessageEncryption) tenantByID(ctx context.Context, tenantID int64) (*entity.ImTenant, error) {
var tenant *entity.ImTenant
if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantID).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取商户失败")
}
if tenant == nil || tenant.Id == 0 {
return nil, gerror.New("商户不存在")
}
return tenant, nil
}
func (s *sSysImMessageEncryption) tenantByCode(ctx context.Context, code string) (*entity.ImTenant, error) {
var tenant *entity.ImTenant
if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().Code, strings.TrimSpace(code)).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取商户失败")
}
if tenant == nil || tenant.Id == 0 {
return nil, gerror.New("商户不存在")
}
return tenant, nil
}
+104 -2
View File
@@ -92,7 +92,7 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
if err = in.Filter(ctx); err != nil {
return
}
tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId)
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return 0, err
}
@@ -123,7 +123,11 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
}
version = uint(maxValue) + 1
}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: policyJSON(in.SensitiveWordPolicy, "{}"), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: normalizedModerationPolicy(in.SensitiveWordPolicy, tenantCode), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
if in.Id > 0 && in.LoginRateLimit == nil {
// 未提交登录失败策略时保留原值(登录失败锁定已迁移至独立配置入口,避免保存草稿清空配置)
delete(data, c.LoginRateLimit)
}
if in.Id > 0 {
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update(); updateErr != nil {
return updateErr
@@ -158,6 +162,20 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
return id, nil
}
func normalizedModerationPolicy(value *gjson.Json, _ string) string {
policy := map[string]any{
"scenes": map[string]bool{"user_nickname": true, "user_signature": true, "group_name": true, "group_nickname": true, "private_message": true, "group_message": true, "group_announcement": true},
"risk_actions": map[string]string{"high": "block", "medium": "block", "low": "audit_only", "none": "allow"},
"failure_mode": "allow", "block_message": "内容不符合平台规范,请修改后重试",
}
if value != nil && value.String() != "" && value.String() != "null" {
for key, item := range value.Map() {
policy[key] = item
}
}
return gjson.New(policy).String()
}
func (s *sSysImSecurityPolicy) Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) error {
if err := in.Filter(ctx); err != nil {
return err
@@ -238,6 +256,90 @@ func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImS
return id, err
}
// GetLoginLockoutConfig 读取租户已发布策略中的登录失败锁定配置,无配置时返回默认值。
func (s *sSysImSecurityPolicy) GetLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigGetInp) (res *sysin.ImLoginLockoutConfigModel, err error) {
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
res = &sysin.ImLoginLockoutConfigModel{Enabled: false, PasswordMaxAttempts: 5, LockMinutes: 1440, WindowMinutes: 60}
c := dao.ImSecurityPolicy.Columns()
var row gdb.Record
if row, err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(c.Id, c.PolicyVersion, "CAST("+c.LoginRateLimit+" AS CHAR) login_rate_limit").
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One(); err != nil {
return nil, gerror.Wrap(err, "获取登录失败策略配置失败")
}
if row.IsEmpty() {
return res, nil
}
res.PolicyId = row[c.Id].Int64()
res.PolicyVersion = row[c.PolicyVersion].Uint()
policy := gjson.New(row["login_rate_limit"].String())
res.Enabled = policy.Get("enabled").Bool()
if v := policy.Get("password_max_attempts").Int(); v >= 1 {
res.PasswordMaxAttempts = int(v)
}
if v := policy.Get("lock_seconds").Int(); v >= 60 {
res.LockMinutes = int(v / 60)
}
if v := policy.Get("window_seconds").Int(); v >= 60 {
res.WindowMinutes = int(v / 60)
}
return res, nil
}
// SaveLoginLockoutConfig 保存租户登录失败锁定配置:直接更新已发布策略,保存后通过策略变更事件即时生效。
func (s *sSysImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigSaveInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return err
}
value := map[string]any{"enabled": false}
if in.Enabled {
value = map[string]any{"enabled": true, "password_max_attempts": in.PasswordMaxAttempts, "lock_seconds": in.LockMinutes * 60, "window_seconds": in.WindowMinutes * 60}
}
loginRateLimit := gjson.New(value).String()
c := dao.ImSecurityPolicy.Columns()
db := imtenant.RegistryDB()
var row gdb.Record
if row, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(c.Id, c.PolicyVersion).
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One(); err != nil {
return gerror.Wrap(err, "获取登录失败策略配置失败")
}
operator := contexts.GetUserId(ctx)
now := gtime.Now()
if !row.IsEmpty() {
policyID := row[c.Id].Int64()
version := row[c.PolicyVersion].Uint()
if _, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, policyID).
Data(g.Map{c.LoginRateLimit: loginRateLimit, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); err != nil {
return gerror.Wrap(err, "保存登录失败策略配置失败")
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", policyID, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version)
}
// 租户尚无已发布策略:创建默认策略草稿并直接发布(Publish 内部已发送策略变更事件)
id, saveErr := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "默认安全策略", LoginRateLimit: gjson.New(value), GlobalFriendEnabled: 1, MessageCurfewEnabled: 0})
if saveErr != nil {
return saveErr
}
if err = s.Publish(ctx, &sysin.ImSecurityPolicyPublishInp{Id: id, Reason: "登录失败策略首次配置"}); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", id, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return nil
}
func (s *sSysImSecurityPolicy) IPRuleList(ctx context.Context, in *sysin.ImSecurityIPRuleListInp) (list []*sysin.ImSecurityIPRuleModel, total int, err error) {
if err = in.Filter(ctx); err != nil {
return
+308 -17
View File
@@ -15,8 +15,10 @@ import (
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/adminin"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/tree"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
@@ -25,6 +27,28 @@ import (
type sSysImTenant struct{}
const (
tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled"
tenantConfigLocalModerationEnabled = "local_moderation_enabled"
tenantConfigMessageEncryptionEnabled = "message_encryption_enabled"
tenantConfigRealnameReviewPolicy = "realname_review_policy"
)
// imManageRoleKey 商户级IM管理员角色标识
const imManageRoleKey = "imManage"
type imTenantConfigModeration struct {
Id int64 `json:"id" orm:"id"`
TenantId int64 `json:"tenant_id" orm:"tenant_id"`
MaxUsers uint64 `json:"max_users" orm:"max_users"`
MaxGroups uint64 `json:"max_groups" orm:"max_groups"`
MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"`
AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"`
MessageEncryptionEnabled int `json:"message_encryption_enabled" orm:"message_encryption_enabled"`
RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"`
}
func NewSysImTenant() *sSysImTenant {
return &sSysImTenant{}
}
@@ -61,6 +85,14 @@ func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (lis
dao.ImTenant.Columns().UpdatedAt,
)
if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
tenantId := contexts.GetTenantId(ctx)
if tenantId <= 0 {
err = gerror.New("当前管理员未关联IM租户")
return
}
mod = mod.Where(dao.ImTenant.Columns().Id, tenantId)
}
if in.Id > 0 {
mod = mod.Where(dao.ImTenant.Columns().Id, in.Id)
}
@@ -70,7 +102,7 @@ func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (lis
if in.Name != "" {
mod = mod.WhereLike(dao.ImTenant.Columns().Name, "%"+in.Name+"%")
}
if in.DbName != "" {
if in.DbName != "" && isSuperTenantAdmin(ctx) {
mod = mod.WhereLike(dao.ImTenant.Columns().DbName, "%"+in.DbName+"%")
}
if in.State != nil {
@@ -95,6 +127,12 @@ func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (lis
if err = s.fillListConfig(ctx, list); err != nil {
return
}
// 业务库名仅超管可见,非超管脱敏返回
if !isSuperTenantAdmin(ctx) {
for _, item := range list {
item.DbName = ""
}
}
return
}
@@ -104,13 +142,13 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan
ids = append(ids, item.Id)
}
var configs []*entity.ImTenantConfig
var configs []*imTenantConfigModeration
if err = s.configModel(ctx).
WhereIn(dao.ImTenantConfig.Columns().TenantId, ids).
Scan(&configs); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
configMap := make(map[int64]*entity.ImTenantConfig, len(configs))
configMap := make(map[int64]*imTenantConfigModeration, len(configs))
for _, item := range configs {
configMap[item.TenantId] = item
}
@@ -127,24 +165,71 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan
dsnMap[item.Id] = item.DbDsn
}
super := isSuperTenantAdmin(ctx)
for _, item := range list {
if conf, ok := configMap[item.Id]; ok {
item.MaxUsers = conf.MaxUsers
item.MaxGroups = conf.MaxGroups
item.MaxGroupMembers = conf.MaxGroupMembers
item.AliyunModerationEnabled = conf.AliyunModerationEnabled
item.LocalModerationEnabled = conf.LocalModerationEnabled
item.MessageEncryptionEnabled = conf.MessageEncryptionEnabled
item.RealnameReviewPolicy = conf.RealnameReviewPolicy
}
item.DbDsnConfigured = dsnMap[item.Id] != ""
item.DbDsnConfigured = super && dsnMap[item.Id] != ""
}
return
}
// Edit 修改/新增IM租户
// Add 新增IM租户
func (s *sSysImTenant) Add(ctx context.Context, in *sysin.ImTenantEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以新增商户")
}
in.Id = 0
return s.saveTenant(ctx, in)
}
// Edit 修改IM租户
func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
if in.Id <= 0 {
return gerror.New("商户ID不能为空")
}
if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
tenantId := contexts.GetTenantId(ctx)
if tenantId <= 0 {
return gerror.New("当前管理员未关联IM租户")
}
if in.Id != tenantId {
return gerror.New("无权编辑其它商户")
}
}
return s.saveTenant(ctx, in)
}
return imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
// isSuperTenantAdmin 当前操作者是否为admin超管
func isSuperTenantAdmin(ctx context.Context) bool {
return service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))
}
func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp) (err error) {
super := isSuperTenantAdmin(ctx)
isCreate := in.Id == 0
tenantID := in.Id
// 业务库名与独立连接串仅超管可维护,非超管提交一律强制忽略
if !super {
in.DbName = ""
in.DbDsn = ""
}
err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil {
return
}
@@ -152,22 +237,168 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
userId := contexts.GetUserId(ctx)
if in.Id > 0 {
in.UpdatedBy = userId
data := g.Map{
dao.ImTenant.Columns().Code: in.Code,
dao.ImTenant.Columns().Name: in.Name,
dao.ImTenant.Columns().TenantDesc: in.TenantDesc,
dao.ImTenant.Columns().State: in.State,
dao.ImTenant.Columns().UpdatedBy: userId,
}
if super {
data[dao.ImTenant.Columns().DbName] = in.DbName
data[dao.ImTenant.Columns().DbDsn] = in.DbDsn
}
if _, err = s.Model(ctx).
Fields(sysin.ImTenantUpdateFields{}).
WherePri(in.Id).Data(in).Update(); err != nil {
WherePri(in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
return s.saveConfig(ctx, in, userId)
}
in.CreatedBy = userId
in.UpdatedBy = userId
id, err := s.Model(ctx, &handler.Option{FilterAuth: false}).
Fields(sysin.ImTenantInsertFields{}).
Data(in).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "新增IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
tenantID = id
in.Id = id
return s.saveConfig(ctx, in, userId)
})
if err != nil {
return err
}
// 新增商户时自动创建绑定的后台管理员账号,失败则补偿回滚商户数据
if isCreate {
if err = s.createTenantAdminMember(ctx, super, in, tenantID); err != nil {
s.rollbackTenantCreation(ctx, tenantID)
return err
}
} else if in.AdminPassword != "" {
// 编辑商户时填写了新密码则重置管理员密码,留空不修改
if err = s.resetTenantAdminPassword(ctx, tenantID, in.AdminPassword); err != nil {
return err
}
}
_ = publishImPolicyEvent(ctx, tenantID, in.Code, "tenant.features.updated", "moderation", 0)
return nil
}
// createTenantAdminMember 为新增商户自动创建后台管理员账号(默认绑定imManage角色)
func (s *sSysImTenant) createTenantAdminMember(ctx context.Context, super bool, in *sysin.ImTenantEditInp, tenantId int64) (err error) {
var role *entity.AdminRole
if err = dao.AdminRole.Ctx(ctx).
Fields(dao.AdminRole.Columns().Id).
Where(dao.AdminRole.Columns().Key, imManageRoleKey).
Where(dao.AdminRole.Columns().Status, consts.StatusEnabled).
OrderAsc(dao.AdminRole.Columns().Id).
Scan(&role); err != nil {
return gerror.Wrap(err, "查询IM管理员角色失败,请稍后重试!")
}
if role == nil || role.Id <= 0 {
return gerror.New("IM管理员角色(imManage)未配置,请联系运维")
}
// 部门挂载:超管挂公司部门;非超管受部门数据权限限制,只能从自己部门的下级中选取
var deptId int64
if super {
companyDeptId, deptErr := dao.AdminDept.Ctx(ctx).
Fields(dao.AdminDept.Columns().Id).
Where(dao.AdminDept.Columns().Type, consts.DeptTypeCompany).
Where(dao.AdminDept.Columns().Status, consts.StatusEnabled).
OrderAsc(dao.AdminDept.Columns().Id).
Limit(1).
Value()
if deptErr != nil {
return gerror.Wrap(deptErr, "获取公司部门失败,请稍后重试!")
}
if companyDeptId.IsEmpty() {
return gerror.New("请先配置公司部门")
}
deptId = companyDeptId.Int64()
} else {
mb := contexts.GetUser(ctx)
if mb == nil || mb.DeptId <= 0 {
return gerror.New("获取当前用户部门失败")
}
subDeptId, deptErr := dao.AdminDept.Ctx(ctx).
Fields(dao.AdminDept.Columns().Id).
Where(dao.AdminDept.Columns().Status, consts.StatusEnabled).
WhereNot(dao.AdminDept.Columns().Id, mb.DeptId).
WhereLike(dao.AdminDept.Columns().Tree, "%"+tree.GetIdLabel(mb.DeptId)+"%").
OrderAsc(dao.AdminDept.Columns().Id).
Limit(1).
Value()
if deptErr != nil {
return gerror.Wrap(deptErr, "获取下级部门失败,请稍后重试!")
}
if subDeptId.IsEmpty() {
return gerror.New("当前部门下没有可用的下级部门来挂载商户管理员账号,请先配置下级部门")
}
deptId = subDeptId.Int64()
}
// 透传底层可读错误,便于前端定位
return service.AdminMember().Edit(ctx, &adminin.MemberEditInp{
TenantId: tenantId,
RoleId: role.Id,
Username: in.AdminUsername,
Password: in.AdminPassword,
RealName: in.Name + "管理员",
DeptId: deptId,
Status: consts.StatusEnabled,
})
}
// resetTenantAdminPassword 重置商户管理员账号的密码(编辑弹窗填写新密码时调用)
func (s *sSysImTenant) resetTenantAdminPassword(ctx context.Context, tenantId int64, password string) (err error) {
roleVal, err := dao.AdminRole.Ctx(ctx).
Fields(dao.AdminRole.Columns().Id).
Where(dao.AdminRole.Columns().Key, imManageRoleKey).
Where(dao.AdminRole.Columns().Status, consts.StatusEnabled).
Limit(1).
Value()
if err != nil {
return gerror.Wrap(err, "查询IM管理员角色失败,请稍后重试!")
}
if roleVal.IsEmpty() {
return gerror.New("IM管理员角色(imManage)未配置,请联系运维")
}
memberId, err := dao.AdminMember.Ctx(ctx).
Fields(dao.AdminMember.Columns().Id).
Where(dao.AdminMember.Columns().TenantId, tenantId).
Where(dao.AdminMember.Columns().RoleId, roleVal.Int64()).
OrderAsc(dao.AdminMember.Columns().Id).
Limit(1).
Value()
if err != nil {
return gerror.Wrap(err, "查询商户管理员账号失败,请稍后重试!")
}
if memberId.IsEmpty() {
return gerror.New("该商户暂无管理员账号,无法重置密码")
}
return service.AdminMember().ResetPwd(ctx, &adminin.MemberResetPwdInp{
Id: memberId.Int64(),
Password: password,
})
}
// rollbackTenantCreation 商户创建补偿:删除已写入的商户及配置记录
func (s *sSysImTenant) rollbackTenantCreation(ctx context.Context, tenantId int64) {
_ = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).
WherePri(tenantId).Delete(); err != nil {
return
}
_, err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).Delete()
return
})
}
@@ -186,18 +417,34 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin
return
}
func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, userId int64) (err error) {
func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp, userId int64) (err error) {
if in.MessageEncryptionEnabled == 1 {
count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
Where("tenant_id", in.Id).
Where("status", consts.ImMessageKeyStatusActive).
Count()
if queryErr != nil {
return gerror.Wrap(queryErr, "检查商户消息密钥失败")
}
if count == 0 {
return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥")
}
}
var conf *entity.ImTenantConfig
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
data := g.Map{
dao.ImTenantConfig.Columns().MaxUsers: maxUsers,
dao.ImTenantConfig.Columns().MaxGroups: maxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers,
dao.ImTenantConfig.Columns().MaxUsers: in.MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups: in.MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers,
tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled,
tenantConfigLocalModerationEnabled: in.LocalModerationEnabled,
tenantConfigMessageEncryptionEnabled: in.MessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy,
}
if conf != nil && conf.Id > 0 {
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
@@ -206,6 +453,10 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
tenantConfigMessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy,
dao.ImTenantConfig.Columns().UpdatedBy,
).
WherePri(conf.Id).Data(data).Update(); err != nil {
@@ -214,15 +465,21 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers
return
}
data[dao.ImTenantConfig.Columns().TenantId] = tenantId
data[dao.ImTenantConfig.Columns().TenantId] = in.Id
data[dao.ImTenantConfig.Columns().CreatedBy] = userId
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
if _, err = s.configModel(ctx).
Fields(
dao.ImTenantConfig.Columns().TenantId,
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
tenantConfigMessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy,
dao.ImTenantConfig.Columns().CreatedBy,
dao.ImTenantConfig.Columns().UpdatedBy,
).
Data(data).Insert(); err != nil {
return gerror.Wrap(err, "新增IM租户配置失败,请稍后重试!")
@@ -243,7 +500,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
return
}
var conf *entity.ImTenantConfig
var conf *imTenantConfigModeration
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
@@ -254,8 +511,37 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
res.MaxUsers = conf.MaxUsers
res.MaxGroups = conf.MaxGroups
res.MaxGroupMembers = conf.MaxGroupMembers
res.AliyunModerationEnabled = conf.AliyunModerationEnabled
res.LocalModerationEnabled = conf.LocalModerationEnabled
res.MessageEncryptionEnabled = conf.MessageEncryptionEnabled
res.RealnameReviewPolicy = conf.RealnameReviewPolicy
}
res.DbDsnConfigured = res.DbDsn != ""
// 查询商户自动创建的管理员账号(编辑弹窗展示)
roleVal, roleErr := dao.AdminRole.Ctx(ctx).
Fields(dao.AdminRole.Columns().Id).
Where(dao.AdminRole.Columns().Key, imManageRoleKey).
Where(dao.AdminRole.Columns().Status, consts.StatusEnabled).
Limit(1).
Value()
if roleErr == nil && !roleVal.IsEmpty() {
userVal, userErr := dao.AdminMember.Ctx(ctx).
Fields(dao.AdminMember.Columns().Username).
Where(dao.AdminMember.Columns().TenantId, in.Id).
Where(dao.AdminMember.Columns().RoleId, roleVal.Int64()).
OrderAsc(dao.AdminMember.Columns().Id).
Limit(1).
Value()
if userErr == nil && !userVal.IsEmpty() {
res.AdminUsername = userVal.String()
}
}
// 业务库名与独立连接串仅超管可见,非超管全部脱敏
if !isSuperTenantAdmin(ctx) {
res.DbName = ""
res.DbDsn = ""
res.DbDsnConfigured = false
}
return
}
@@ -295,6 +581,11 @@ func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (r
DbDsnConfigured: tenant.DbDsn != "",
Warnings: []string{},
}
// 业务库名与连接串信息仅超管可见,非超管全部脱敏
if !isSuperTenantAdmin(ctx) {
res.DbName = ""
res.DbDsnConfigured = false
}
db, _, dbErr := imtenant.DB(ctx, tenant.Id)
if dbErr != nil {
+54
View File
@@ -206,8 +206,13 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []
values.Set("user_ids", strings.Join(userIds, ","))
}
if 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)
}
@@ -360,6 +365,7 @@ func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model {
"up.public_account",
"up.gender",
"up.region",
"COALESCE(up.friend_add_enabled, 1) friend_add_enabled",
"us.auth_version",
"us.password_updated_at",
)
@@ -589,6 +595,54 @@ func (s *sSysImUser) AdjustGroup(ctx context.Context, in *sysin.ImUserAdjustGrou
return nil
}
func (s *sSysImUser) FriendAdd(ctx context.Context, in *sysin.ImUserFriendAddInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return err
}
db, _, err := imtenant.DB(ctx, scope.TenantId)
if err != nil {
return err
}
now := gtime.Now()
result, err := db.Model("im_user_profiles").Safe().Ctx(ctx).
Where("user_id", in.UserId).
Data(g.Map{
"friend_add_enabled": in.Enabled,
"friend_add_disabled_reason": in.Reason,
"friend_add_policy_version": gdb.Raw("friend_add_policy_version+1"),
"updated_at": now,
}).Update()
if err != nil {
return gerror.Wrap(err, "设置用户加好友开关失败")
}
affected, _ := result.RowsAffected()
if affected == 0 {
profileID, idErr := imtenant.NextSnowflakeID(ctx)
if idErr != nil {
return gerror.Wrap(idErr, "生成用户资料ID失败")
}
_, err = db.Model("im_user_profiles").Safe().Ctx(ctx).Data(g.Map{
"id": profileID,
"user_id": in.UserId,
"public_account": fmt.Sprintf("usr%d", in.UserId),
"friend_add_enabled": in.Enabled,
"friend_add_disabled_reason": in.Reason,
"friend_add_policy_version": 1,
"created_at": now,
"updated_at": now,
}).Insert()
if err != nil {
return gerror.Wrap(err, "补全用户资料并设置加好友开关失败")
}
}
_ = writeImAdminAudit(ctx, scope.TenantId, "im_user_friend_add_update", "user", in.UserId, "success", g.Map{"enabled": in.Enabled, "reason": in.Reason})
return nil
}
func (s *sSysImUser) IPWhitelist(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (*sysin.ImUserIPWhitelistModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
+37 -100
View File
@@ -2,18 +2,15 @@ package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"net/url"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imWorkbenchAppsTable = "im_workbench_apps"
type sSysImWorkbench struct{}
func NewSysImWorkbench() *sSysImWorkbench { return &sSysImWorkbench{} }
@@ -26,47 +23,46 @@ func (s *sSysImWorkbench) List(ctx context.Context, in *sysin.ImWorkbenchAppList
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureDefaultApps(ctx, db); err != nil {
return
}
mod := s.model(ctx, db)
values := url.Values{}
values.Set("page", intToString(in.Page))
values.Set("per_page", intToString(in.PerPage))
if in.Code != "" {
mod = mod.WhereLike("code", "%"+in.Code+"%")
values.Set("code", in.Code)
}
if in.Name != "" {
mod = mod.WhereLike("name", "%"+in.Name+"%")
values.Set("name", in.Name)
}
if in.Enabled != nil {
mod = mod.Where("enabled", *in.Enabled)
values.Set("enabled", intToString(*in.Enabled))
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("sort_order").OrderAsc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM工作台模块失败,请稍后重试!")
return
var page imworkbench.Page[sysin.ImWorkbenchAppModel]
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/apps", values, &page); err != nil {
return nil, 0, err
}
for _, item := range list {
list = make([]*sysin.ImWorkbenchAppModel, 0, len(page.Items))
for i := range page.Items {
item := page.Items[i]
item.TenantId = tenant.Id
item.TenantName = tenant.Name
list = append(list, &item)
}
return
return list, page.Total, nil
}
func (s *sSysImWorkbench) Edit(ctx context.Context, in *sysin.ImWorkbenchAppEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureDefaultApps(ctx, db); err != nil {
return
}
data := g.Map{
"id": in.Id,
"code": in.Code,
"name": in.Name,
"icon_file_id": in.IconFileId,
@@ -75,25 +71,18 @@ func (s *sSysImWorkbench) Edit(ctx context.Context, in *sysin.ImWorkbenchAppEdit
"unread_key": in.UnreadKey,
"enabled": in.Enabled,
"permission": in.Permission,
"updated_at": gtime.Now(),
}
action := "im_workbench_app_create"
targetId := in.Id
if in.Id > 0 {
if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).WherePri(in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新IM工作台模块失败,请稍后重试!")
}
action = "im_workbench_app_update"
} else {
data["created_at"] = gtime.Now()
if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "新增IM工作台模块失败,请稍后重试!")
}
idValue, err := db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Fields("id").Where("code", in.Code).Value()
if err != nil {
return gerror.Wrap(err, "获取IM工作台模块ID失败,请稍后重试!")
var res sysin.ImWorkbenchAppModel
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps", data, &res); err != nil {
return err
}
targetId = idValue.Int64()
if targetId <= 0 {
targetId = res.Id
}
_ = writeImAdminAudit(ctx, tenant.Id, action, "im_workbench_app", targetId, "success", g.Map{
"code": in.Code, "route": in.Route, "enabled": in.Enabled, "permission": in.Permission,
@@ -105,21 +94,12 @@ func (s *sSysImWorkbench) Delete(ctx context.Context, in *sysin.ImWorkbenchAppDe
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureDefaultApps(ctx, db); err != nil {
return
}
mod := db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx)
if in.Id > 0 {
mod = mod.WherePri(in.Id)
} else {
mod = mod.Where("code", in.Code)
}
if _, err = mod.Data(g.Map{"enabled": consts.ImWorkbenchAppDisabled, "updated_at": gtime.Now()}).Update(); err != nil {
return gerror.Wrap(err, "禁用IM工作台模块失败,请稍后重试!")
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps/disable", g.Map{"id": in.Id, "code": in.Code}, nil); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_workbench_app_disable", "im_workbench_app", in.Id, "success", g.Map{"code": in.Code})
return nil
@@ -129,66 +109,23 @@ func (s *sSysImWorkbench) Reorder(ctx context.Context, in *sysin.ImWorkbenchAppR
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureDefaultApps(ctx, db); err != nil {
return
}
if err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
items := make([]g.Map, 0, len(in.Items))
for _, item := range in.Items {
if _, e := tx.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Where("code", item.Code).
Data(g.Map{"sort_order": item.SortOrder, "updated_at": gtime.Now()}).Update(); e != nil {
return e
items = append(items, g.Map{
"code": item.Code,
"sort_order": item.SortOrder,
})
}
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps/reorder", g.Map{"items": items}, nil); err != nil {
if err.Error() == "" {
return gerror.New("调整IM工作台模块排序失败,请稍后重试!")
}
return nil
}); err != nil {
return gerror.Wrap(err, "调整IM工作台模块排序失败,请稍后重试!")
return err
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_workbench_app_reorder", "im_workbench_app", 0, "success", g.Map{"count": len(in.Items)})
return nil
}
func (s *sSysImWorkbench) model(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Fields(
"id",
"code",
"name",
"icon_file_id",
"route",
"sort_order",
"unread_key",
"enabled",
"permission",
"created_at",
"updated_at",
)
}
func (s *sSysImWorkbench) ensureDefaultApps(ctx context.Context, db gdb.DB) (err error) {
defaults := []g.Map{
{"code": "organization", "name": "组织架构", "route": consts.ImWorkbenchRouteOrganization, "sort_order": 10, "unread_key": "organization", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled},
{"code": "meetings", "name": "会议通知", "route": consts.ImWorkbenchRouteMeetings, "sort_order": 20, "unread_key": "meetings", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled},
{"code": "daily_reports", "name": "日报", "route": consts.ImWorkbenchRouteDailyReports, "sort_order": 30, "unread_key": "daily_reports", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled},
{"code": "monthly_reports", "name": "月报", "route": consts.ImWorkbenchRouteMonthlyReports, "sort_order": 40, "unread_key": "monthly_reports", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled},
{"code": "executive_mailbox", "name": "总经理信箱", "route": consts.ImWorkbenchRouteExecutiveMailbox, "sort_order": 50, "unread_key": "executive_mailbox", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled},
}
now := gtime.Now()
for _, item := range defaults {
var count int
if count, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Where("code", item["code"]).Count(); err != nil {
return gerror.Wrap(err, "检查IM工作台默认模块失败,请稍后重试!")
}
if count > 0 {
continue
}
item["created_at"] = now
item["updated_at"] = now
if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Data(item).Insert(); err != nil {
return gerror.Wrap(err, "初始化IM工作台默认模块失败,请稍后重试!")
}
}
return nil
}
@@ -0,0 +1,29 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// AdminOperationLog is the golang structure of table cv_admin_operation_log for DAO operations like Where/Data.
type AdminOperationLog struct {
g.Meta `orm:"table:cv_admin_operation_log, do:true"`
Id any //
TenantId any //
OperatorId any //
OperationType any //
TargetType any //
TargetId any //
RequestId any //
BeforeSnapshot *gjson.Json //
AfterSnapshot *gjson.Json //
Reason any //
IpAddress any //
UserAgent any //
CreatedAt *gtime.Time //
}
@@ -0,0 +1,29 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ContentAuditLog is the golang structure of table cv_content_audit_log for DAO operations like Where/Data.
type ContentAuditLog struct {
g.Meta `orm:"table:cv_content_audit_log, do:true"`
Id any //
TenantId any //
ObjectType any //
ObjectId any //
AuditRequestId any //
AuditSource any //
StatusBefore any //
StatusAfter any //
ResultCode any //
RiskLabels *gjson.Json //
OperatorId any //
OccurredAt *gtime.Time //
CreatedAt *gtime.Time //
}
+29
View File
@@ -0,0 +1,29 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedComment is the golang structure of table cv_feed_comment for DAO operations like Where/Data.
type FeedComment struct {
g.Meta `orm:"table:cv_feed_comment, do:true"`
Id any //
TenantId any //
PostId any //
AuthorId any //
ContentText any //
Status any //
AuditStatus any //
AuditRequestId any //
DeletedBy any //
DeleteReason any //
PublishedAt *gtime.Time //
DeletedAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+29
View File
@@ -0,0 +1,29 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedMedia is the golang structure of table cv_feed_media for DAO operations like Where/Data.
type FeedMedia struct {
g.Meta `orm:"table:cv_feed_media, do:true"`
Id any //
TenantId any //
PostId any //
UserId any //
MediaType any //
FileId any //
ThumbnailFileId any //
Width any //
Height any //
SortNo any //
AuditStatus any //
AuditRequestId any //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+35
View File
@@ -0,0 +1,35 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedPost is the golang structure of table cv_feed_post for DAO operations like Where/Data.
type FeedPost struct {
g.Meta `orm:"table:cv_feed_post, do:true"`
Id any //
TenantId any //
AuthorId any //
ContentText any //
FlowerCode any //
SourceType any //
SourceId any //
Visibility any //
Status any //
AuditStatus any //
AuditRequestId any //
RejectReasonCode any //
LikeCount any //
CommentCount any //
Version any //
PublishedAt *gtime.Time //
DeletedAt *gtime.Time //
TakenDownAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+32
View File
@@ -0,0 +1,32 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedReport is the golang structure of table cv_feed_report for DAO operations like Where/Data.
type FeedReport struct {
g.Meta `orm:"table:cv_feed_report, do:true"`
Id any //
TenantId any //
ReporterId any //
ReportedUserId any //
ObjectType any //
ObjectId any //
ReasonCode any //
Description any //
EvidenceFileIds *gjson.Json //
SourcePage any //
Status any //
HandlerId any //
HandleResult any //
HandledAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
@@ -0,0 +1,32 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerCatalog is the golang structure of table cv_flower_catalog for DAO operations like Where/Data.
type FlowerCatalog struct {
g.Meta `orm:"table:cv_flower_catalog, do:true"`
Id any //
TenantId any //
FlowerCode any //
FlowerName any //
FlowerDesc any //
IconFileId any //
Status any //
ConfigVersion any //
DailyWaterLimit any //
DailyFertilizeLimit any //
ShareConfig *gjson.Json //
SortNo any //
OnlineAt *gtime.Time //
OfflineAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
@@ -0,0 +1,32 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerStageRule is the golang structure of table cv_flower_stage_rule for DAO operations like Where/Data.
type FlowerStageRule struct {
g.Meta `orm:"table:cv_flower_stage_rule, do:true"`
Id any //
TenantId any //
FlowerCatalogId any //
Stage any //
NextStage any //
RequiredPoints any //
WaterPoints any //
FertilizePoints any //
AllowedActions *gjson.Json //
ImageFileId any //
AnimationFileId any //
ProgressTextTemplate any //
CompletionText any //
SortNo any //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
@@ -0,0 +1,27 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// AdminOperationLog is the golang structure for table admin_operation_log.
type AdminOperationLog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
OperatorId uint64 `json:"operatorId" orm:"operator_id" description:""`
OperationType string `json:"operationType" orm:"operation_type" description:""`
TargetType string `json:"targetType" orm:"target_type" description:""`
TargetId string `json:"targetId" orm:"target_id" description:""`
RequestId string `json:"requestId" orm:"request_id" description:""`
BeforeSnapshot *gjson.Json `json:"beforeSnapshot" orm:"before_snapshot" description:""`
AfterSnapshot *gjson.Json `json:"afterSnapshot" orm:"after_snapshot" description:""`
Reason string `json:"reason" orm:"reason" description:""`
IpAddress string `json:"ipAddress" orm:"ip_address" description:""`
UserAgent string `json:"userAgent" orm:"user_agent" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
}
@@ -0,0 +1,27 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// ContentAuditLog is the golang structure for table content_audit_log.
type ContentAuditLog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
ObjectType string `json:"objectType" orm:"object_type" description:""`
ObjectId uint64 `json:"objectId" orm:"object_id" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
AuditSource string `json:"auditSource" orm:"audit_source" description:""`
StatusBefore string `json:"statusBefore" orm:"status_before" description:""`
StatusAfter string `json:"statusAfter" orm:"status_after" description:""`
ResultCode string `json:"resultCode" orm:"result_code" description:""`
RiskLabels *gjson.Json `json:"riskLabels" orm:"risk_labels" description:""`
OperatorId uint64 `json:"operatorId" orm:"operator_id" description:""`
OccurredAt *gtime.Time `json:"occurredAt" orm:"occurred_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
}
@@ -0,0 +1,27 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedComment is the golang structure for table feed_comment.
type FeedComment struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
PostId uint64 `json:"postId" orm:"post_id" description:""`
AuthorId uint64 `json:"authorId" orm:"author_id" description:""`
ContentText string `json:"contentText" orm:"content_text" description:""`
Status string `json:"status" orm:"status" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:""`
DeleteReason string `json:"deleteReason" orm:"delete_reason" description:""`
PublishedAt *gtime.Time `json:"publishedAt" orm:"published_at" description:""`
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -0,0 +1,27 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedMedia is the golang structure for table feed_media.
type FeedMedia struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
PostId uint64 `json:"postId" orm:"post_id" description:""`
UserId uint64 `json:"userId" orm:"user_id" description:""`
MediaType string `json:"mediaType" orm:"media_type" description:""`
FileId string `json:"fileId" orm:"file_id" description:""`
ThumbnailFileId string `json:"thumbnailFileId" orm:"thumbnail_file_id" description:""`
Width uint `json:"width" orm:"width" description:""`
Height uint `json:"height" orm:"height" description:""`
SortNo uint `json:"sortNo" orm:"sort_no" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
+33
View File
@@ -0,0 +1,33 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedPost is the golang structure for table feed_post.
type FeedPost struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
AuthorId uint64 `json:"authorId" orm:"author_id" description:""`
ContentText string `json:"contentText" orm:"content_text" description:""`
FlowerCode string `json:"flowerCode" orm:"flower_code" description:""`
SourceType string `json:"sourceType" orm:"source_type" description:""`
SourceId uint64 `json:"sourceId" orm:"source_id" description:""`
Visibility string `json:"visibility" orm:"visibility" description:""`
Status string `json:"status" orm:"status" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
RejectReasonCode string `json:"rejectReasonCode" orm:"reject_reason_code" description:""`
LikeCount uint `json:"likeCount" orm:"like_count" description:""`
CommentCount uint `json:"commentCount" orm:"comment_count" description:""`
Version uint `json:"version" orm:"version" description:""`
PublishedAt *gtime.Time `json:"publishedAt" orm:"published_at" description:""`
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""`
TakenDownAt *gtime.Time `json:"takenDownAt" orm:"taken_down_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedReport is the golang structure for table feed_report.
type FeedReport struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
ReporterId uint64 `json:"reporterId" orm:"reporter_id" description:""`
ReportedUserId uint64 `json:"reportedUserId" orm:"reported_user_id" description:""`
ObjectType string `json:"objectType" orm:"object_type" description:""`
ObjectId string `json:"objectId" orm:"object_id" description:""`
ReasonCode string `json:"reasonCode" orm:"reason_code" description:""`
Description string `json:"description" orm:"description" description:""`
EvidenceFileIds *gjson.Json `json:"evidenceFileIds" orm:"evidence_file_ids" description:""`
SourcePage string `json:"sourcePage" orm:"source_page" description:""`
Status string `json:"status" orm:"status" description:""`
HandlerId uint64 `json:"handlerId" orm:"handler_id" description:""`
HandleResult string `json:"handleResult" orm:"handle_result" description:""`
HandledAt *gtime.Time `json:"handledAt" orm:"handled_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerCatalog is the golang structure for table flower_catalog.
type FlowerCatalog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
FlowerCode string `json:"flowerCode" orm:"flower_code" description:""`
FlowerName string `json:"flowerName" orm:"flower_name" description:""`
FlowerDesc string `json:"flowerDesc" orm:"flower_desc" description:""`
IconFileId string `json:"iconFileId" orm:"icon_file_id" description:""`
Status string `json:"status" orm:"status" description:""`
ConfigVersion string `json:"configVersion" orm:"config_version" description:""`
DailyWaterLimit uint `json:"dailyWaterLimit" orm:"daily_water_limit" description:""`
DailyFertilizeLimit uint `json:"dailyFertilizeLimit" orm:"daily_fertilize_limit" description:""`
ShareConfig *gjson.Json `json:"shareConfig" orm:"share_config" description:""`
SortNo int `json:"sortNo" orm:"sort_no" description:""`
OnlineAt *gtime.Time `json:"onlineAt" orm:"online_at" description:""`
OfflineAt *gtime.Time `json:"offlineAt" orm:"offline_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerStageRule is the golang structure for table flower_stage_rule.
type FlowerStageRule struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
FlowerCatalogId uint64 `json:"flowerCatalogId" orm:"flower_catalog_id" description:""`
Stage string `json:"stage" orm:"stage" description:""`
NextStage string `json:"nextStage" orm:"next_stage" description:""`
RequiredPoints uint `json:"requiredPoints" orm:"required_points" description:""`
WaterPoints uint `json:"waterPoints" orm:"water_points" description:""`
FertilizePoints uint `json:"fertilizePoints" orm:"fertilize_points" description:""`
AllowedActions *gjson.Json `json:"allowedActions" orm:"allowed_actions" description:""`
ImageFileId string `json:"imageFileId" orm:"image_file_id" description:""`
AnimationFileId string `json:"animationFileId" orm:"animation_file_id" description:""`
ProgressTextTemplate string `json:"progressTextTemplate" orm:"progress_text_template" description:""`
CompletionText string `json:"completionText" orm:"completion_text" description:""`
SortNo int `json:"sortNo" orm:"sort_no" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -0,0 +1,290 @@
package sysin
import (
"context"
"strconv"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"hotgo/internal/model/input/form"
)
type CultivateContentListInp struct {
form.PageReq
ObjectType string `json:"objectType" d:"feed_post"`
AuthorId string `json:"authorId"`
Status string `json:"status"`
Keyword string `json:"keyword"`
CreatedAt []*gtime.Time `json:"createdAt"`
}
func (in *CultivateContentListInp) Filter(context.Context) error {
in.ObjectType = strings.TrimSpace(in.ObjectType)
if in.ObjectType != "feed_post" && in.ObjectType != "feed_comment" {
return gerror.New("内容类型不正确")
}
if in.AuthorId != "" {
if _, err := strconv.ParseUint(in.AuthorId, 10, 64); err != nil {
return gerror.New("作者ID不正确")
}
}
return nil
}
type CultivateContentListModel struct {
Id string `json:"id"`
ObjectType string `json:"objectType"`
PostId string `json:"postId"`
AuthorId string `json:"authorId"`
ContentText string `json:"contentText"`
FlowerCode string `json:"flowerCode"`
Visibility string `json:"visibility"`
Status string `json:"status"`
AuditStatus string `json:"auditStatus"`
RejectReason string `json:"rejectReason"`
LikeCount uint `json:"likeCount"`
CommentCount uint `json:"commentCount"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
type CultivateContentViewInp struct {
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
Id string `json:"id" v:"required#内容ID不能为空"`
ObjectId uint64 `json:"-"`
}
func (in *CultivateContentViewInp) Filter(context.Context) error {
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
if err != nil || id == 0 {
return gerror.New("内容ID不正确")
}
in.ObjectId = id
return nil
}
type CultivateMediaModel struct {
Id string `json:"id"`
FileId string `json:"fileId"`
ThumbnailFileId string `json:"thumbnailFileId"`
MediaType string `json:"mediaType"`
AuditStatus string `json:"auditStatus"`
SortNo uint `json:"sortNo"`
}
type CultivateAuditModel struct {
Id string `json:"id"`
AuditSource string `json:"auditSource"`
StatusBefore string `json:"statusBefore"`
StatusAfter string `json:"statusAfter"`
ResultCode string `json:"resultCode"`
RiskLabels any `json:"riskLabels"`
OperatorId string `json:"operatorId"`
OccurredAt *gtime.Time `json:"occurredAt"`
}
type CultivateContentViewModel struct {
CultivateContentListModel
AuthorNickname string `json:"authorNickname"`
AuthorAvatarFileId string `json:"authorAvatarFileId"`
Media []*CultivateMediaModel `json:"media"`
Audits []*CultivateAuditModel `json:"audits"`
RelatedReports []*CultivateReportListModel `json:"relatedReports"`
}
type CultivateContentReviewInp struct {
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
Id string `json:"id" v:"required#内容ID不能为空"`
Action string `json:"action" v:"required|in:approve,reject,take_down,restore#审核动作不能为空|审核动作不正确"`
Reason string `json:"reason"`
ObjectId uint64 `json:"-"`
}
type CultivateContentBatchReviewInp struct {
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
Ids []string `json:"ids" v:"required#请选择内容"`
Action string `json:"action" v:"required|in:approve,reject,take_down,restore#审核动作不能为空|审核动作不正确"`
Reason string `json:"reason"`
}
func (in *CultivateContentBatchReviewInp) Filter(ctx context.Context) error {
if len(in.Ids) == 0 || len(in.Ids) > 100 {
return gerror.New("单次批量处理数量必须为1至100条")
}
in.Reason = strings.TrimSpace(in.Reason)
if (in.Action == "reject" || in.Action == "take_down") && in.Reason == "" {
return gerror.New("拒绝或下架时必须填写原因")
}
for _, id := range in.Ids {
item := &CultivateContentReviewInp{ObjectType: in.ObjectType, Id: id, Action: in.Action, Reason: in.Reason}
if err := item.Filter(ctx); err != nil {
return err
}
}
return nil
}
type CultivateContentBatchReviewModel struct {
SuccessCount int `json:"successCount"`
FailedCount int `json:"failedCount"`
Failures map[string]string `json:"failures"`
}
func (in *CultivateContentReviewInp) Filter(context.Context) error {
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
if err != nil || id == 0 {
return gerror.New("内容ID不正确")
}
in.ObjectId = id
in.Reason = strings.TrimSpace(in.Reason)
if (in.Action == "reject" || in.Action == "take_down") && in.Reason == "" {
return gerror.New("拒绝或下架时必须填写原因")
}
return nil
}
type CultivateReportListInp struct {
form.PageReq
ObjectType string `json:"objectType"`
Status string `json:"status"`
ReporterId string `json:"reporterId"`
CreatedAt []*gtime.Time `json:"createdAt"`
}
func (in *CultivateReportListInp) Filter(context.Context) error {
if in.ReporterId != "" {
if _, err := strconv.ParseUint(in.ReporterId, 10, 64); err != nil {
return gerror.New("举报人ID不正确")
}
}
return nil
}
type CultivateReportListModel struct {
Id string `json:"id"`
ReporterId string `json:"reporterId"`
ReportedUserId string `json:"reportedUserId"`
ObjectType string `json:"objectType"`
ObjectId string `json:"objectId"`
ReasonCode string `json:"reasonCode"`
Description string `json:"description"`
EvidenceFileIds any `json:"evidenceFileIds"`
SourcePage string `json:"sourcePage"`
Status string `json:"status"`
HandlerId string `json:"handlerId"`
HandleResult string `json:"handleResult"`
HandledAt *gtime.Time `json:"handledAt"`
CreatedAt *gtime.Time `json:"createdAt"`
}
type CultivateReportViewInp struct {
Id string `json:"id" v:"required#举报ID不能为空"`
ReportId uint64 `json:"-"`
}
func (in *CultivateReportViewInp) Filter(context.Context) error {
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
if err != nil || id == 0 {
return gerror.New("举报ID不正确")
}
in.ReportId = id
return nil
}
type CultivateReportViewModel struct {
CultivateReportListModel
ReporterNickname string `json:"reporterNickname"`
ReportedUserNickname string `json:"reportedUserNickname"`
TargetContent *CultivateContentListModel `json:"targetContent"`
History []*CultivateReportListModel `json:"history"`
}
type CultivateReportHandleInp struct {
Id string `json:"id" v:"required#举报ID不能为空"`
Action string `json:"action" v:"required|in:processing,resolve,reject#处理动作不能为空|处理动作不正确"`
HandleResult string `json:"handleResult"`
ContentAction string `json:"contentAction" v:"in:none,take_down#内容处置不正确"`
UserAction string `json:"userAction" v:"in:none,freeze,ban#用户处置不正确"`
Reason string `json:"reason"`
ReportId uint64 `json:"-"`
}
func (in *CultivateReportHandleInp) Filter(context.Context) error {
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
if err != nil || id == 0 {
return gerror.New("举报ID不正确")
}
in.ReportId = id
in.HandleResult = strings.TrimSpace(in.HandleResult)
in.ContentAction = strings.TrimSpace(in.ContentAction)
in.UserAction = strings.TrimSpace(in.UserAction)
in.Reason = strings.TrimSpace(in.Reason)
if in.ContentAction == "" {
in.ContentAction = "none"
}
if in.UserAction == "" {
in.UserAction = "none"
}
if in.Action != "processing" && in.HandleResult == "" {
return gerror.New("办结举报时必须填写处理结果")
}
if (in.ContentAction != "none" || in.UserAction != "none") && in.Reason == "" {
return gerror.New("联动处置时必须填写处置原因")
}
if in.Action != "resolve" && (in.ContentAction != "none" || in.UserAction != "none") {
return gerror.New("仅确认处理时可执行内容或用户处置")
}
return nil
}
type CultivateOperationLogListInp struct {
form.PageReq
OperatorId string `json:"operatorId"`
OperationType string `json:"operationType"`
TargetType string `json:"targetType"`
TargetId string `json:"targetId"`
RequestId string `json:"requestId"`
CreatedAt []*gtime.Time `json:"createdAt"`
}
type CultivateDashboardInp struct{}
func (*CultivateDashboardInp) Filter(context.Context) error { return nil }
type CultivateDashboardModel struct {
PlantCount int64 `json:"plantCount"`
GrowingCount int64 `json:"growingCount"`
BloomCount int64 `json:"bloomCount"`
HarvestedCount int64 `json:"harvestedCount"`
RemovedCount int64 `json:"removedCount"`
Active7d int64 `json:"active7d"`
Active30d int64 `json:"active30d"`
PostCount int64 `json:"postCount"`
PublicPostCount int64 `json:"publicPostCount"`
FriendsPostCount int64 `json:"friendsPostCount"`
PrivatePostCount int64 `json:"privatePostCount"`
CommentCount int64 `json:"commentCount"`
LikeCount int64 `json:"likeCount"`
TakenDownCount int64 `json:"takenDownCount"`
PendingAuditCount int64 `json:"pendingAuditCount"`
ApprovedAuditCount int64 `json:"approvedAuditCount"`
RejectedAuditCount int64 `json:"rejectedAuditCount"`
PendingReportCount int64 `json:"pendingReportCount"`
ProcessingReportCount int64 `json:"processingReportCount"`
ProcessedReportCount int64 `json:"processedReportCount"`
}
func (*CultivateOperationLogListInp) Filter(context.Context) error { return nil }
type CultivateOperationLogListModel struct {
Id string `json:"id"`
OperatorId string `json:"operatorId"`
OperationType string `json:"operationType"`
TargetType string `json:"targetType"`
TargetId string `json:"targetId"`
RequestId string `json:"requestId"`
BeforeSnapshot any `json:"beforeSnapshot"`
AfterSnapshot any `json:"afterSnapshot"`
Reason string `json:"reason"`
CreatedAt *gtime.Time `json:"createdAt"`
}
@@ -0,0 +1,27 @@
package sysin
import (
"context"
"testing"
)
func TestCultivateContentBatchReviewFilter(t *testing.T) {
in := &CultivateContentBatchReviewInp{ObjectType: "feed_post", Ids: []string{"123"}, Action: "take_down", Reason: "违规内容"}
if err := in.Filter(context.Background()); err != nil {
t.Fatalf("Filter returned error: %v", err)
}
}
func TestCultivateContentBatchReviewFilterRequiresReason(t *testing.T) {
in := &CultivateContentBatchReviewInp{ObjectType: "feed_post", Ids: []string{"123"}, Action: "reject"}
if err := in.Filter(context.Background()); err == nil {
t.Fatal("expected missing reason error")
}
}
func TestCultivateReportHandleFilterGovernanceRules(t *testing.T) {
in := &CultivateReportHandleInp{Id: "123", Action: "processing", UserAction: "freeze", Reason: "恶意行为"}
if err := in.Filter(context.Background()); err == nil {
t.Fatal("expected governance action to require resolve")
}
}
@@ -0,0 +1,155 @@
package sysin
import (
"context"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"hotgo/internal/model/input/form"
)
type CultivateStageRuleEditInp struct {
Id uint64 `json:"id"`
Stage string `json:"stage"`
NextStage string `json:"nextStage"`
RequiredPoints uint `json:"requiredPoints"`
WaterPoints uint `json:"waterPoints"`
FertilizePoints uint `json:"fertilizePoints"`
AllowedActions []string `json:"allowedActions"`
ImageFileId string `json:"imageFileId"`
AnimationFileId string `json:"animationFileId"`
ProgressTextTemplate string `json:"progressTextTemplate"`
CompletionText string `json:"completionText"`
SortNo int `json:"sortNo"`
}
type CultivateFlowerEditInp struct {
Id uint64 `json:"id"`
FlowerCode string `json:"flowerCode"`
FlowerName string `json:"flowerName"`
FlowerDesc string `json:"flowerDesc"`
IconFileId string `json:"iconFileId"`
Status string `json:"status"`
DailyWaterLimit uint `json:"dailyWaterLimit"`
DailyFertilizeLimit uint `json:"dailyFertilizeLimit"`
ShareConfig *gjson.Json `json:"shareConfig"`
SortNo int `json:"sortNo"`
StageRules []*CultivateStageRuleEditInp `json:"stageRules"`
}
func (in *CultivateFlowerEditInp) Filter(ctx context.Context) error {
in.FlowerCode = strings.TrimSpace(in.FlowerCode)
in.FlowerName = strings.TrimSpace(in.FlowerName)
if in.FlowerCode == "" || in.FlowerName == "" {
return gerror.New("花种编码和名称不能为空")
}
validStatus := map[string]bool{"online": true, "offline": true, "activity_only": true, "maintenance": true}
if !validStatus[in.Status] {
return gerror.New("花种状态不正确")
}
if len(in.StageRules) == 0 {
return gerror.New("至少配置一个成长阶段")
}
validStage := map[string]bool{"seed": true, "sprout": true, "bud": true, "bloom": true}
validAction := map[string]bool{"water": true, "fertilize": true}
seen := map[string]bool{}
for _, rule := range in.StageRules {
if rule == nil || !validStage[rule.Stage] {
return gerror.New("成长阶段不正确")
}
if seen[rule.Stage] {
return gerror.New("成长阶段不能重复")
}
seen[rule.Stage] = true
if rule.Stage != "bloom" && rule.RequiredPoints == 0 {
return gerror.New("非开花阶段升级阈值必须大于0")
}
if rule.NextStage != "" && !validStage[rule.NextStage] {
return gerror.New("下一阶段不正确")
}
if rule.Stage != "bloom" && len(rule.AllowedActions) == 0 {
return gerror.New("非开花阶段至少允许一种成长动作")
}
for _, action := range rule.AllowedActions {
if !validAction[action] {
return gerror.New("成长动作不正确")
}
}
}
stage := "seed"
visited := map[string]bool{}
for stage != "" {
if visited[stage] {
return gerror.New("成长阶段链不能形成循环")
}
visited[stage] = true
var next string
found := false
for _, r := range in.StageRules {
if r.Stage == stage {
next = r.NextStage
found = true
break
}
}
if !found {
return gerror.New("成长阶段链必须从 seed 连续配置")
}
stage = next
}
if !visited["bloom"] {
return gerror.New("成长阶段链必须以 bloom 结束")
}
return nil
}
type CultivateFlowerListInp struct {
form.PageReq
FlowerCode string `json:"flowerCode"`
FlowerName string `json:"flowerName"`
Status string `json:"status"`
ConfigVersion string `json:"configVersion"`
CreatedAt []*gtime.Time `json:"createdAt"`
}
func (*CultivateFlowerListInp) Filter(context.Context) error { return nil }
type CultivateFlowerListModel struct {
Id uint64 `json:"id"`
FlowerCode string `json:"flowerCode"`
FlowerName string `json:"flowerName"`
FlowerDesc string `json:"flowerDesc"`
IconFileId string `json:"iconFileId"`
Status string `json:"status"`
ConfigVersion string `json:"configVersion"`
DailyWaterLimit uint `json:"dailyWaterLimit"`
DailyFertilizeLimit uint `json:"dailyFertilizeLimit"`
SortNo int `json:"sortNo"`
StageCount int `json:"stageCount"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
type CultivateFlowerViewInp struct {
Id uint64 `json:"id" v:"required#ID不能为空"`
}
func (*CultivateFlowerViewInp) Filter(context.Context) error { return nil }
type CultivateFlowerViewModel struct {
CultivateFlowerEditInp
ConfigVersion string `json:"configVersion"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
type CultivateFlowerStatusInp struct {
Id uint64 `json:"id" v:"required#ID不能为空"`
Status string `json:"status" v:"required|in:online,offline,activity_only,maintenance#状态不能为空|状态不正确"`
Reason string `json:"reason"`
}
func (*CultivateFlowerStatusInp) Filter(context.Context) error { return nil }
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More