Compare commits
88
Commits
bd19a594ae
...
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba837f4cae | ||
|
|
0553424ef9 | ||
|
|
64037863ef | ||
|
|
db2eb62277 | ||
|
|
a3527a6d22 | ||
|
|
8d6618dac1 | ||
|
|
2c5995073f | ||
|
|
315d97895b | ||
|
|
3834af70b5 | ||
|
|
667e62e97e | ||
|
|
55ae8b2acd | ||
|
|
5a7d8407bc | ||
|
|
837a03a87e | ||
|
|
0edbc63bc1 | ||
|
|
9e01f95a61 | ||
|
|
fce8dbbe32 | ||
|
|
43f3581b1c | ||
|
|
9924c1e522 | ||
|
|
e87ac92cd1 | ||
|
|
e24a04a920 | ||
|
|
32017a2fbf | ||
|
|
369898bd7c | ||
|
|
e002e45f82 | ||
|
|
abadcceb19 | ||
|
|
5a07f63716 | ||
|
|
6c338626d6 | ||
|
|
22adc13acf | ||
|
|
432e7ac51a | ||
|
|
dd12dbfe07 | ||
|
|
4fbc896d75 | ||
|
|
603e89e204 | ||
|
|
abd5eb162a | ||
|
|
935bfe17a0 | ||
|
|
671ac2e1f7 | ||
|
|
dc3c45cd53 | ||
|
|
9a9e30f94e | ||
|
|
85b564e2ac | ||
|
|
851eca97ed | ||
|
|
0596599db9 | ||
|
|
40ac52923e | ||
|
|
c9673ca871 | ||
|
|
197c6c86f4 | ||
|
|
a2f328f97e | ||
|
|
c4ab970fb7 | ||
|
|
8fad32e396 | ||
|
|
6f331a07fc | ||
|
|
b4174a6412 | ||
|
|
0381984939 | ||
|
|
6348ace38e | ||
|
|
e8dd1eaa7d | ||
|
|
42cfe232fe | ||
|
|
4ae69706a5 | ||
|
|
3ef328a5a3 | ||
|
|
2b93d27a4e | ||
|
|
97990785cb | ||
|
|
b68c048383 | ||
|
|
f154b9e3c3 | ||
|
|
e2ca0607af | ||
|
|
6afeb65b91 | ||
|
|
e5ab79c40d | ||
|
|
8675fe333d | ||
|
|
fd4ff85191 | ||
|
|
6bf364ec0b | ||
|
|
47ed097923 | ||
|
|
e5cfc80916 | ||
|
|
c7265252d8 | ||
|
|
f6766d3dbd | ||
|
|
7c8b4d9b7c | ||
|
|
348cbcaf96 | ||
|
|
2a35cb4358 | ||
|
|
be6c5b525c | ||
|
|
9ed8aaf142 | ||
|
|
4ed54ca392 | ||
|
|
5173bc0aad | ||
|
|
fec2a43b9c | ||
|
|
32491e9cdd | ||
|
|
fe5c7af7e2 | ||
|
|
156e8b9c9f | ||
|
|
f8aaae42ff | ||
|
|
82379b8823 | ||
|
|
3e40554229 | ||
|
|
e159f9a88e | ||
|
|
c258e0473b | ||
|
|
5dbbf39cce | ||
|
|
50a2781b23 | ||
|
|
a6f7ac41b0 | ||
|
|
c1a6a41575 | ||
|
|
f44e8adb72 |
+56
-30
@@ -1,42 +1,68 @@
|
||||
name: Deploy im-manager (Blue-Green)
|
||||
name: Deploy im-manager production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- pro
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像tag,留空则使用commit短哈希'
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: production-im-manager
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.olns.shop
|
||||
IMAGE: git.olns.shop/pro/im_manager
|
||||
SERVICE: im-manager
|
||||
DEPLOY_HOST: 145.223.69.9
|
||||
DEPLOY_USER: olns-deploy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 确定镜像tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 构建镜像(前端+后端一体,自包含多阶段构建)
|
||||
run: docker build -t im-manager:${{ steps.vars.outputs.tag }} .
|
||||
|
||||
- name: 蓝绿部署
|
||||
- name: Log in to Gitea Container Registry
|
||||
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 }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
IMAGE=im-manager:${{ steps.vars.outputs.tag }} /opt/apps/im-manager/deploy.sh
|
||||
set -euo pipefail
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||
docker build -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||
docker push "$IMAGE:pro"
|
||||
docker push "$IMAGE:$SHORT_SHA"
|
||||
|
||||
- name: Deploy Compose service
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||
cleanup() {
|
||||
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/known_hosts"
|
||||
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login git.olns.shop -u admin --password-stdin"
|
||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd /opt/olns && docker compose pull im-manager && docker compose up -d --no-deps --wait im-manager"
|
||||
|
||||
@@ -163,3 +163,6 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Trae local debugging artifacts
|
||||
.trae/
|
||||
|
||||
|
||||
+27
-14
@@ -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,70 @@
|
||||
# IM Manager 发布冒烟测试记录
|
||||
|
||||
测试时间:2026-09-23 02:50 CST
|
||||
|
||||
测试环境:
|
||||
|
||||
- Manager:`https://admin.118im.com/admin`
|
||||
- 登录账号:`potter`
|
||||
- 角色:平台超管 `supreme_manager`
|
||||
- 租户:`default`,`tenantId=1`
|
||||
|
||||
## 通过接口
|
||||
|
||||
以下接口返回 `code=0`:
|
||||
|
||||
| 模块 | 接口 | 数据量 |
|
||||
| --- | --- | --- |
|
||||
| 租户列表 | `GET /imTenant/list?page=1&pageSize=10` | 8 |
|
||||
| 租户详情 | `GET /imTenant/view?id=1` | 有数据 |
|
||||
| 租户用量 | `GET /imTenant/usage?id=1` | 有数据 |
|
||||
| 企业注册申请 | `GET /imTenantApplication/list?page=1&pageSize=10` | 10 |
|
||||
| 企业注册详情 | `GET /imTenantApplication/view?id=4` | 有数据 |
|
||||
| 企业反馈 | `GET /imTenantEntryFeedback/list?page=1&pageSize=10` | 3 |
|
||||
| 用户列表 | `GET /imUser/list?tenantId=1&page=1&pageSize=10` | 10 |
|
||||
| 群组列表 | `GET /imGroup/list?tenantId=1&page=1&pageSize=10` | 0 |
|
||||
| 实名认证 | `GET /imRealname/list?tenantId=1&page=1&pageSize=10` | 1 |
|
||||
| 群消息 | `GET /imGroupMessage/list?tenantId=1&page=1&pageSize=10` | 0 |
|
||||
| 登录历史 | `GET /imUserLoginHistory/list?tenantId=1&page=1&pageSize=10` | 10 |
|
||||
| 工作台概览 | `GET /imDashboard/overview?tenantId=1` | 有数据 |
|
||||
| 趋势图 | `GET /imDashboard/trends?tenantId=1` | 7 |
|
||||
| 通知模板 | `GET /imNotification/templateList?tenantId=1&page=1&pageSize=10` | 7 |
|
||||
| 文件列表 | `GET /imFile/list?tenantId=1&page=1&pageSize=10` | 10 |
|
||||
| 审计日志 | `GET /imAuditLog/list?tenantId=1&page=1&pageSize=10` | 0 |
|
||||
| 外部配置 | `GET /imExternalConfig/list?tenantId=1&page=1&pageSize=20` | 5 |
|
||||
| 文件策略 | `GET /imFilePolicy/list?tenantId=1&page=1&pageSize=10` | 6 |
|
||||
| App 版本 | `GET /imAppRelease/list?page=1&pageSize=10` | 3 |
|
||||
| 安全策略 | `GET /imSecurityPolicy/list?tenantId=1&page=1&pageSize=10` | 1 |
|
||||
| 用户策略分组 | `GET /imUserPolicyGroup/list?tenantId=1&page=1&pageSize=10` | 3 |
|
||||
| 敏感词分组 | `GET /imSensitiveWord/groupList?tenantId=1&page=1&pageSize=10` | 6 |
|
||||
| 敏感词 | `GET /imSensitiveWord/list?tenantId=1&page=1&pageSize=10` | 10 |
|
||||
| 举报原因 | `GET /imAbuseReason/list?tenantId=1&page=1&pageSize=10` | 10 |
|
||||
| 注册邀请码 | `POST /imInvite/list` | 10 |
|
||||
| Kafka 审计 | `GET /imKafkaAudit/list?page=1&pageSize=10` | 10 |
|
||||
| Kafka 命令 | `GET /imKafkaCommand/list?page=1&pageSize=10&tenantId=1` | 10 |
|
||||
| 死信列表 | `GET /imDeadLetter/list?page=1&pageSize=10&tenantId=1` | 0 |
|
||||
|
||||
## 发现问题
|
||||
|
||||
| 问题 | 现象 | 定位 |
|
||||
| --- | --- | --- |
|
||||
| 注册配置平台角色被业务层拦截 | `GET /imRegistration/config/get?tenantId=1` 返回 `仅平台超级管理员可操作IM注册配置` | `admin` 账号可访问,`potter/supreme_manager` 不可访问;Manager 代码只认内置 super ID,未复用 IM 平台管理员判断 |
|
||||
| 注册配置隐藏权限 SQL 不完整 | 测试库已补权限后可过菜单鉴权,但生成 SQL 缺少 `/imRegistration/config/get`、`/imRegistration/config/update` | 已补生成 SQL |
|
||||
| 组织架构查询不可用 | `/imOrg/departmentList`、`/imOrg/memberList` 返回 `resource not found` | 直连 IM Server `/internal/admin-query/tenants/default/org/departments` 也是 404,线上 IM Server 未暴露该 internal query 资源 |
|
||||
| 私聊消息查询不可用 | `/imPrivateMessage/list` 返回 `resource not found` | 直连 IM Server `/internal/admin-query/tenants/default/private-messages` 也是 404;同环境 `group-messages` 正常 |
|
||||
| 登录失败日志不可用 | `/imLoginFailureLog/list` 返回 `resource not found` | 直连 IM Server `/internal/admin-query/tenants/default/login-failure-logs` 也是 404;IM Server 当前代码测试里也标注该资源已移除 |
|
||||
| 工作台管理接口不可用 | `/imWorkbench/list`、`/imFeedbackConfig/get`、`/imMailboxConfig/get` 返回 `internal error` | `admin` 与 `potter` 均失败,基本排除菜单权限;需检查线上 Manager `imWorkbenchApi` 配置和 `im_workbench` 服务运行时配置 |
|
||||
|
||||
## 本次 Manager 修复
|
||||
|
||||
- 注册配置接口改为复用 `isImPlatformAdmin`,允许 `supreme_manager` 平台角色访问。
|
||||
- 邀请码菜单生成 SQL 补充注册配置的隐藏权限。
|
||||
- 本地发布配置已补 `imWorkbenchApi`,但 `server/manifest/config/config.yaml` 为 git 忽略文件,线上仍需确认实际 Secret/ConfigMap 是否包含工作台地址与密钥。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
go test ./internal/logic/sys ./internal/library/imworkbench
|
||||
```
|
||||
|
||||
结果:通过。
|
||||
@@ -0,0 +1,697 @@
|
||||
# Cultivate Server 管理后台内部接口开发对接文档
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文用于指导 `cultivate-server` 开发管理后台内部接口,使 IM Manager 可以通过服务间 HTTP API 管理养成业务,不再直接连接 Cultivate 数据库。
|
||||
|
||||
改造完成后必须满足:
|
||||
|
||||
1. IM Manager 不配置 `CULTIVATE_MYSQL_DSN`,也不持有 Cultivate 数据库账号。
|
||||
2. Cultivate 数据只能由 `cultivate-server` 读写。
|
||||
3. Manager 只负责管理员登录态、RBAC、页面协议转换和调用内部接口。
|
||||
4. Cultivate 服务不可用时,只影响养成管理菜单,不得影响 Manager 启动及其他菜单。
|
||||
5. 所有查询和写入必须按商户隔离,禁止信任前端传入的 `tenant_id`、`operator_id`。
|
||||
|
||||
## 2. 当前问题
|
||||
|
||||
当前 IM Manager 在配置中注册了独立数据库组:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
cultivate:
|
||||
link: "$CULTIVATE_MYSQL_DSN"
|
||||
```
|
||||
|
||||
养成管理逻辑通过 `g.DB("cultivate")` 及 group 为 `cultivate` 的 DAO 直接访问以下表:
|
||||
|
||||
- `cv_flower_catalog`
|
||||
- `cv_flower_stage_rule`
|
||||
- `cv_user_flower`
|
||||
- `cv_garden_active_day`
|
||||
- `cv_feed_post`
|
||||
- `cv_feed_comment`
|
||||
- `cv_feed_media`
|
||||
- `cv_feed_report`
|
||||
- `cv_content_audit_log`
|
||||
- `cv_admin_operation_log`
|
||||
|
||||
该实现造成:
|
||||
|
||||
- Manager 启动依赖 Cultivate 数据库配置。
|
||||
- Cultivate 数据库账号扩散到 Manager。
|
||||
- Cultivate 表结构变化会直接影响 Manager。
|
||||
- 内容审核、通知、文件校验等事务边界跨服务泄漏。
|
||||
|
||||
本次应将上述数据库逻辑迁移到 `cultivate-server`,不能以给 Manager 增加数据库 Secret 的方式规避。
|
||||
|
||||
## 3. 服务职责
|
||||
|
||||
### 3.1 Cultivate Server
|
||||
|
||||
负责:
|
||||
|
||||
- 查询养成概览。
|
||||
- 花种及成长阶段配置的增删改查和状态变更。
|
||||
- 动态、评论审核列表、详情和人工审核。
|
||||
- 举报列表、详情和处理。
|
||||
- 管理操作日志查询和写入。
|
||||
- 调用 IM 内部接口查询用户摘要、校验文件、发送系统通知及执行用户治理。
|
||||
- 数据事务、幂等、租户隔离和审计。
|
||||
|
||||
### 3.2 IM Manager
|
||||
|
||||
负责:
|
||||
|
||||
- 校验后台管理员登录态和菜单权限。
|
||||
- 从登录态解析商户、操作人和角色。
|
||||
- 对 Cultivate 内部请求签名。
|
||||
- 将 Cultivate 返回结果转换为现有后台页面响应。
|
||||
- 将下游不可用错误转换成清晰的中文业务错误。
|
||||
|
||||
Manager 不再保存 Cultivate DAO、实体模型或数据库连接。
|
||||
|
||||
## 4. 接口基础约定
|
||||
|
||||
### 4.1 路径
|
||||
|
||||
内部管理接口统一使用:
|
||||
|
||||
```text
|
||||
/internal/admin/cultivate/tenants/{tenant_code}/...
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
GET /internal/admin/cultivate/tenants/default/dashboard
|
||||
```
|
||||
|
||||
这些接口只能在集群内访问,不挂到公网 Ingress。
|
||||
|
||||
### 4.2 数据格式
|
||||
|
||||
- 请求和响应均使用 UTF-8 JSON。
|
||||
- JSON 字段统一使用 `snake_case`。
|
||||
- ID 统一使用十进制字符串返回,避免 JavaScript 超出安全整数范围。
|
||||
- 时间统一使用 UTC RFC3339,例如 `2026-09-23T08:30:00Z`。
|
||||
- 分页参数为 `page`、`per_page`,默认分别为 `1`、`20`。
|
||||
- `per_page` 最大为 `100`。
|
||||
|
||||
统一成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"server_time": "2026-09-23T08:30:00Z",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
统一失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 40001,
|
||||
"reason": "INVALID_ARGUMENT",
|
||||
"message": "内容类型不正确",
|
||||
"details": null,
|
||||
"server_time": "2026-09-23T08:30:00Z",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
分页数据统一放在 `data` 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 内部鉴权
|
||||
|
||||
### 5.1 请求头
|
||||
|
||||
| 请求头 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `X-Service-Name` | 是 | 调用方服务名,Manager 固定为 `im-manager` |
|
||||
| `X-Timestamp` | 是 | UTC Unix 秒 |
|
||||
| `X-Nonce` | 是 | 每次请求随机值,服务端防重放 |
|
||||
| `X-Signature` | 是 | HMAC-SHA256 十六进制小写字符串 |
|
||||
| `X-Tenant-ID` | 是 | Manager 登录态解析出的商户 ID |
|
||||
| `X-Operator-ID` | 是 | Manager 登录态解析出的管理员 ID |
|
||||
| `X-Role-Key` | 否 | 管理员角色标识,用于审计,不作为唯一授权依据 |
|
||||
| `X-Request-ID` | 是 | 全链路请求 ID |
|
||||
|
||||
### 5.2 签名串
|
||||
|
||||
双方复用现有内部服务密钥,不新增数据库相关 Secret。签名原文为:
|
||||
|
||||
```text
|
||||
METHOD\n
|
||||
REQUEST_URI\n
|
||||
SHA256_HEX(BODY)\n
|
||||
TIMESTAMP\n
|
||||
NONCE\n
|
||||
TENANT_ID\n
|
||||
OPERATOR_ID\n
|
||||
ROLE_KEY\n
|
||||
REQUEST_ID
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `REQUEST_URI` 包含路径和排序后的 Query String。
|
||||
- GET 请求的 Body 为空字节数组。
|
||||
- 使用共享密钥计算 `HMAC-SHA256`。
|
||||
- 时间窗口建议为正负 300 秒。
|
||||
- `X-Service-Name + X-Nonce` 在时间窗口内只能使用一次。
|
||||
- 必须使用常量时间比较签名。
|
||||
- 商户、操作人及角色头已经纳入签名,业务层不得接受 Body 中同名字段覆盖。
|
||||
|
||||
### 5.3 商户校验
|
||||
|
||||
1. `{tenant_code}` 必须与签名后的 `X-Tenant-ID` 对应。
|
||||
2. 所有 SQL 必须带 `tenant_id = X-Tenant-ID`。
|
||||
3. 查不到商户或编码与 ID 不匹配时返回 `TENANT_NOT_FOUND` 或 `TENANT_MISMATCH`。
|
||||
4. 禁止使用默认商户兜底处理错误的商户参数。
|
||||
|
||||
## 6. 接口清单
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/dashboard` | 养成管理概览 |
|
||||
| GET | `/flowers` | 花种列表 |
|
||||
| GET | `/flowers/{id}` | 花种详情 |
|
||||
| POST | `/flowers` | 新建花种 |
|
||||
| PUT | `/flowers/{id}` | 编辑花种 |
|
||||
| POST | `/flowers/{id}/status` | 花种状态变更 |
|
||||
| GET | `/content-reviews` | 内容审核列表 |
|
||||
| GET | `/content-reviews/{object_type}/{id}` | 内容审核详情 |
|
||||
| POST | `/content-reviews/{object_type}/{id}/actions` | 单条审核操作 |
|
||||
| POST | `/content-reviews/batch-actions` | 批量审核操作 |
|
||||
| GET | `/reports` | 举报列表 |
|
||||
| GET | `/reports/{id}` | 举报详情 |
|
||||
| POST | `/reports/{id}/actions` | 处理举报 |
|
||||
| GET | `/operation-logs` | 操作日志列表 |
|
||||
|
||||
以下章节中的路径均省略前缀:
|
||||
|
||||
```text
|
||||
/internal/admin/cultivate/tenants/{tenant_code}
|
||||
```
|
||||
|
||||
## 7. 数据概览
|
||||
|
||||
### 7.1 `GET /dashboard`
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plant_count": 0,
|
||||
"growing_count": 0,
|
||||
"bloom_count": 0,
|
||||
"harvested_count": 0,
|
||||
"removed_count": 0,
|
||||
"active_7d": 0,
|
||||
"active_30d": 0,
|
||||
"post_count": 0,
|
||||
"public_post_count": 0,
|
||||
"friends_post_count": 0,
|
||||
"private_post_count": 0,
|
||||
"comment_count": 0,
|
||||
"like_count": 0,
|
||||
"taken_down_count": 0,
|
||||
"pending_audit_count": 0,
|
||||
"approved_audit_count": 0,
|
||||
"rejected_audit_count": 0,
|
||||
"pending_report_count": 0,
|
||||
"processing_report_count": 0,
|
||||
"processed_report_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
统计口径必须沿用当前业务状态,且全部按商户过滤。没有数据时返回 `0`,不能返回 `null`。
|
||||
|
||||
## 8. 花种管理
|
||||
|
||||
### 8.1 `GET /flowers`
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `flower_code` | string | 编码模糊查询 |
|
||||
| `flower_name` | string | 名称模糊查询 |
|
||||
| `status` | string | 状态精确查询 |
|
||||
| `config_version` | string | 配置版本 |
|
||||
| `created_from` | RFC3339 | 创建开始时间 |
|
||||
| `created_to` | RFC3339 | 创建结束时间 |
|
||||
| `page` | int | 页码 |
|
||||
| `per_page` | int | 每页数量 |
|
||||
|
||||
列表项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "10001",
|
||||
"flower_code": "rose",
|
||||
"flower_name": "玫瑰",
|
||||
"flower_desc": "",
|
||||
"icon_file_id": "file_xxx",
|
||||
"status": "online",
|
||||
"config_version": "cat_20260923_163000",
|
||||
"daily_water_limit": 3,
|
||||
"daily_fertilize_limit": 1,
|
||||
"sort_no": 10,
|
||||
"stage_count": 4,
|
||||
"created_at": "2026-09-23T08:30:00Z",
|
||||
"updated_at": "2026-09-23T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
排序规则:`sort_no ASC, id DESC`。
|
||||
|
||||
### 8.2 `GET /flowers/{id}`
|
||||
|
||||
在列表字段基础上增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"share_config": {},
|
||||
"stage_rules": [
|
||||
{
|
||||
"id": "10002",
|
||||
"stage": "seed",
|
||||
"next_stage": "sprout",
|
||||
"required_points": 10,
|
||||
"water_points": 2,
|
||||
"fertilize_points": 3,
|
||||
"wait_hours": 36,
|
||||
"allowed_actions": ["water", "fertilize"],
|
||||
"image_file_id": "file_xxx",
|
||||
"animation_file_id": "file_xxx",
|
||||
"progress_text_template": "成长中",
|
||||
"completion_text": "已进入发芽阶段",
|
||||
"sort_no": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 `POST /flowers` 与 `PUT /flowers/{id}`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"flower_code": "rose",
|
||||
"flower_name": "玫瑰",
|
||||
"flower_desc": "",
|
||||
"icon_file_id": "file_xxx",
|
||||
"status": "offline",
|
||||
"daily_water_limit": 3,
|
||||
"daily_fertilize_limit": 1,
|
||||
"share_config": {},
|
||||
"sort_no": 10,
|
||||
"stage_rules": []
|
||||
}
|
||||
```
|
||||
|
||||
业务规则:
|
||||
|
||||
- `flower_code`、`flower_name` 必填。
|
||||
- `flower_code` 创建后不可修改。
|
||||
- 状态枚举:`online`、`offline`、`activity_only`、`maintenance`。
|
||||
- 阶段枚举:`seed`、`sprout`、`bud`、`bloom`。
|
||||
- 阶段链必须从 `seed` 连续配置并以 `bloom` 结束,不能重复或成环。
|
||||
- 非 `bloom` 阶段 `required_points` 与 `wait_hours` 不能同时为 0(2026-10-08 起:升级条件 = 积分就绪 AND 等待就绪,允许纯积分或纯等待阶段),且至少允许一个动作。
|
||||
- 动作枚举:`water`、`fertilize`。
|
||||
- 文件 ID 必须通过 IM 内部文件校验,风险或待审核素材不能保存。
|
||||
- 花种和阶段规则必须在一个数据库事务内保存。
|
||||
- 每次新增或编辑生成新的 `config_version` 并写管理操作日志。
|
||||
- 新建返回 `data.id`,ID 使用字符串。
|
||||
|
||||
### 8.4 `POST /flowers/{id}/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "online",
|
||||
"reason": "审核通过后上架"
|
||||
}
|
||||
```
|
||||
|
||||
更新上下架时间、配置版本并写操作日志。
|
||||
|
||||
## 9. 内容审核
|
||||
|
||||
### 9.1 `GET /content-reviews`
|
||||
|
||||
Query 参数:
|
||||
|
||||
- `object_type`:必填,`feed_post` 或 `feed_comment`。
|
||||
- `author_id`:作者 ID。
|
||||
- `status`:业务状态。
|
||||
- `keyword`:正文关键字。
|
||||
- `created_from`、`created_to`。
|
||||
- `page`、`per_page`。
|
||||
|
||||
列表项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "20001",
|
||||
"object_type": "feed_post",
|
||||
"post_id": "",
|
||||
"author_id": "30001",
|
||||
"content_text": "正文",
|
||||
"flower_code": "rose",
|
||||
"visibility": "public",
|
||||
"status": "reviewing",
|
||||
"audit_status": "pending",
|
||||
"reject_reason": "",
|
||||
"like_count": 0,
|
||||
"comment_count": 0,
|
||||
"created_at": "2026-09-23T08:30:00Z",
|
||||
"updated_at": "2026-09-23T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
按 `id DESC` 排序。
|
||||
|
||||
### 9.2 `GET /content-reviews/{object_type}/{id}`
|
||||
|
||||
详情在列表项基础上增加:
|
||||
|
||||
- `author_nickname`
|
||||
- `author_avatar_file_id`
|
||||
- `media[]`:`id`、`file_id`、`thumbnail_file_id`、`media_type`、`audit_status`、`sort_no`
|
||||
- `audits[]`:`id`、`audit_source`、`status_before`、`status_after`、`result_code`、`risk_labels`、`operator_id`、`occurred_at`
|
||||
- `related_reports[]`:关联举报摘要
|
||||
|
||||
用户摘要通过 IM 内部 API 查询。用户服务临时不可用时,允许昵称和头像为空,但内容主体仍应返回。
|
||||
|
||||
### 9.3 `POST /content-reviews/{object_type}/{id}/actions`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "approve",
|
||||
"reason": ""
|
||||
}
|
||||
```
|
||||
|
||||
动作枚举:
|
||||
|
||||
- `approve`:审核通过。
|
||||
- `reject`:审核拒绝,必须填写原因。
|
||||
- `take_down`:下架,必须填写原因。
|
||||
- `restore`:恢复内容。
|
||||
|
||||
要求:
|
||||
|
||||
- 使用行锁或等价并发控制。
|
||||
- 校验合法的前置状态。
|
||||
- 更新内容状态和审核字段。
|
||||
- 评论首次通过时只能增加一次动态评论数。
|
||||
- 下架动态时应按现有业务规则同步处理其可见性或关联评论。
|
||||
- 每次操作写 `cv_content_audit_log` 和 `cv_admin_operation_log`。
|
||||
- 事务提交后向作者发送系统通知。
|
||||
- 重复请求不能重复计数或重复产生治理副作用。
|
||||
|
||||
### 9.4 `POST /content-reviews/batch-actions`
|
||||
|
||||
```json
|
||||
{
|
||||
"object_type": "feed_post",
|
||||
"ids": ["20001", "20002"],
|
||||
"action": "approve",
|
||||
"reason": ""
|
||||
}
|
||||
```
|
||||
|
||||
- 单次 1 至 100 条。
|
||||
- 每条独立处理,单条失败不回滚其他成功项。
|
||||
- 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_count": 1,
|
||||
"failed_count": 1,
|
||||
"failures": {
|
||||
"20002": "当前状态不允许审核通过"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 举报管理
|
||||
|
||||
### 10.1 `GET /reports`
|
||||
|
||||
Query 参数:
|
||||
|
||||
- `object_type`
|
||||
- `status`
|
||||
- `reporter_id`
|
||||
- `created_from`、`created_to`
|
||||
- `page`、`per_page`
|
||||
|
||||
列表项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "40001",
|
||||
"reporter_id": "30001",
|
||||
"reported_user_id": "30002",
|
||||
"object_type": "feed_post",
|
||||
"object_id": "20001",
|
||||
"reason_code": "spam",
|
||||
"description": "疑似广告",
|
||||
"evidence_file_ids": ["file_xxx"],
|
||||
"source_page": "feed_detail",
|
||||
"status": "submitted",
|
||||
"handler_id": "",
|
||||
"handle_result": "",
|
||||
"handled_at": null,
|
||||
"created_at": "2026-09-23T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 `GET /reports/{id}`
|
||||
|
||||
详情增加:
|
||||
|
||||
- `reporter_nickname`
|
||||
- `reported_user_nickname`
|
||||
- `target_content`
|
||||
- `history[]`:同一对象的历史举报
|
||||
|
||||
不得向被举报方的通知或普通业务接口泄露举报人身份和证据。
|
||||
|
||||
### 10.3 `POST /reports/{id}/actions`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "resolve",
|
||||
"handle_result": "举报成立,内容已下架",
|
||||
"content_action": "take_down",
|
||||
"user_action": "none",
|
||||
"reason": "违规广告"
|
||||
}
|
||||
```
|
||||
|
||||
枚举:
|
||||
|
||||
- `action`:`processing`、`resolve`、`reject`。
|
||||
- `content_action`:`none`、`take_down`。
|
||||
- `user_action`:`none`、`freeze`、`ban`。
|
||||
|
||||
校验规则:
|
||||
|
||||
- `processing` 不要求处理结果。
|
||||
- `resolve`、`reject` 必须填写 `handle_result`。
|
||||
- 只有 `resolve` 可以联动内容和用户处置。
|
||||
- 发生联动处置时必须填写 `reason`。
|
||||
- 报告状态更新、内容处置和操作日志应处于同一事务。
|
||||
- 用户冻结或封禁通过 IM 内部治理 API 执行;失败时不得将举报错误标记为已完成。
|
||||
- 完成后分别通知举报方和被举报方,被举报方通知不得携带举报人信息。
|
||||
|
||||
## 11. 操作日志
|
||||
|
||||
### 11.1 `GET /operation-logs`
|
||||
|
||||
Query 参数:
|
||||
|
||||
- `operator_id`
|
||||
- `operation_type`
|
||||
- `target_type`
|
||||
- `target_id`
|
||||
- `request_id`
|
||||
- `created_from`、`created_to`
|
||||
- `page`、`per_page`
|
||||
|
||||
列表项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "50001",
|
||||
"operator_id": "14",
|
||||
"operation_type": "content.approve",
|
||||
"target_type": "feed_post",
|
||||
"target_id": "20001",
|
||||
"request_id": "req_xxx",
|
||||
"before_snapshot": {},
|
||||
"after_snapshot": {},
|
||||
"reason": "",
|
||||
"created_at": "2026-09-23T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
日志只读,不提供删除接口。快照不得保存 Token、手机号、数据库连接、完整举报证据或私有文件 URL。
|
||||
|
||||
## 12. 状态和枚举
|
||||
|
||||
| 类型 | 枚举 |
|
||||
| --- | --- |
|
||||
| 内容类型 | `feed_post`、`feed_comment` |
|
||||
| 花种状态 | `online`、`offline`、`activity_only`、`maintenance` |
|
||||
| 成长阶段 | `seed`、`sprout`、`bud`、`bloom` |
|
||||
| 成长动作 | `water`、`fertilize` |
|
||||
| 可见范围 | `public`、`friends`、`private` |
|
||||
| 内容状态 | `reviewing`、`published`、`rejected`、`taken_down` |
|
||||
| 审核动作 | `approve`、`reject`、`take_down`、`restore` |
|
||||
| 举报状态 | `submitted`、`processing`、`resolved`、`rejected` |
|
||||
| 举报动作 | `processing`、`resolve`、`reject` |
|
||||
| 用户处置 | `none`、`freeze`、`ban` |
|
||||
|
||||
如果现有数据库已经使用其他稳定枚举,开发前应与 Manager 对齐,不允许接口层静默转换出第三套状态。
|
||||
|
||||
## 13. 错误码建议
|
||||
|
||||
| HTTP | reason | 场景 |
|
||||
| --- | --- | --- |
|
||||
| 400 | `INVALID_ARGUMENT` | 参数或枚举错误 |
|
||||
| 401 | `INTERNAL_AUTH_REQUIRED` | 缺少内部签名 |
|
||||
| 403 | `INTERNAL_AUTH_FORBIDDEN` | 签名错误、服务未授权 |
|
||||
| 404 | `TENANT_NOT_FOUND` | 商户不存在 |
|
||||
| 404 | `RESOURCE_NOT_FOUND` | 花种、内容或举报不存在 |
|
||||
| 409 | `STATE_CONFLICT` | 当前状态不允许执行动作 |
|
||||
| 409 | `DUPLICATE_FLOWER_CODE` | 商户内花种编码重复 |
|
||||
| 422 | `ASSET_REVIEW_PENDING` | 素材仍在审核中 |
|
||||
| 422 | `ASSET_REVIEW_REJECTED` | 素材审核未通过 |
|
||||
| 429 | `RATE_LIMITED` | 调用频率过高 |
|
||||
| 503 | `DEPENDENCY_UNAVAILABLE` | IM 用户、文件、通知或治理服务不可用 |
|
||||
| 500 | `INTERNAL_ERROR` | 未知内部错误 |
|
||||
|
||||
响应不得返回 SQL、数据库地址、堆栈或 Secret。
|
||||
|
||||
## 14. 数据一致性与安全要求
|
||||
|
||||
1. 每条 SQL 都必须显式过滤 `tenant_id`。
|
||||
2. 更新语句同时按主键和 `tenant_id` 定位。
|
||||
3. 写接口记录签名后的 `X-Operator-ID` 和 `X-Request-ID`。
|
||||
4. 审核、举报处置和计数变更必须考虑重试幂等。
|
||||
5. 批量操作最多 100 条,避免长事务。
|
||||
6. 私有媒体只返回 `file_id`,访问地址由受控文件接口临时签发。
|
||||
7. 关键操作日志永久保留;普通查询对已注销用户做匿名化。
|
||||
8. 内部接口不可复用 C 端 Token 鉴权,必须单独使用服务间 HMAC。
|
||||
9. 内部 API 路由应在普通 C 端认证中间件之前单独注册,不能误走 Redis 用户会话认证。
|
||||
|
||||
## 15. Cultivate Server 建议目录
|
||||
|
||||
按现有分层新增,禁止在 controller 中写数据库逻辑:
|
||||
|
||||
```text
|
||||
api/internal/admin/
|
||||
cultivate.go
|
||||
internal/controller/admin/
|
||||
cultivate.go
|
||||
internal/service/
|
||||
admin_cultivate.go
|
||||
internal/logic/admin/
|
||||
dashboard.go
|
||||
flower.go
|
||||
content_review.go
|
||||
report.go
|
||||
operation_log.go
|
||||
internal/middleware/
|
||||
internal_admin_auth.go
|
||||
internal/model/
|
||||
admin_cultivate.go
|
||||
```
|
||||
|
||||
路由建议独立注册:
|
||||
|
||||
```text
|
||||
/internal/admin/cultivate/*
|
||||
```
|
||||
|
||||
不要将管理接口混入 `/v1/cultivate/*` C 端路由。
|
||||
|
||||
## 16. Manager 后续改造
|
||||
|
||||
Cultivate Server 接口完成后,Manager 侧将执行:
|
||||
|
||||
1. 新增 Cultivate 内部 API Client。
|
||||
2. 将现有页面 API 转发到本文定义的内部接口。
|
||||
3. 删除 Manager 内的 Cultivate 直接数据库逻辑。
|
||||
4. 删除 Cultivate DAO、DO、Entity 等仅为直连数据库生成的代码。
|
||||
5. 删除配置模板中的 `database.cultivate` 和 `CULTIVATE_MYSQL_DSN`。
|
||||
6. 保持现有后台页面路径和前端交互不变。
|
||||
|
||||
服务地址建议支持配置,但 Kubernetes 环境可提供集群内默认值:
|
||||
|
||||
```text
|
||||
http://cultivate-server.cultivate-system.svc.cluster.local:8080
|
||||
```
|
||||
|
||||
该地址不是数据库配置。密钥复用双方已有内部 HMAC Secret,不增加 Cultivate 数据库 Secret。
|
||||
|
||||
## 17. 测试要求
|
||||
|
||||
### 17.1 单元测试
|
||||
|
||||
- HMAC 正确、错误、过期和 Nonce 重放。
|
||||
- 商户编码与 ID 不匹配。
|
||||
- 所有列表过滤和分页。
|
||||
- 花种阶段链缺失、重复、成环和合法场景。
|
||||
- 内容每种状态动作的合法与非法转换。
|
||||
- 重复审核不重复增加评论数。
|
||||
- 举报各种状态和联动处置。
|
||||
- 跨商户 ID 查询和更新均返回不存在。
|
||||
|
||||
### 17.2 集成测试
|
||||
|
||||
- 使用真实 Cultivate 测试库验证全部接口。
|
||||
- 模拟 IM 用户、文件、通知和治理内部接口。
|
||||
- 验证事务失败不会留下半完成数据。
|
||||
- 验证操作日志中的商户、管理员和请求 ID 正确。
|
||||
- 验证接口响应不包含数据库信息和堆栈。
|
||||
|
||||
### 17.3 联调验收
|
||||
|
||||
1. Manager 不设置 `CULTIVATE_MYSQL_DSN` 可以正常启动。
|
||||
2. Manager 登录、IM 管理和工作台管理均正常。
|
||||
3. 养成概览、花种、内容审核、举报和操作日志页面可正常查询。
|
||||
4. 花种新增、编辑、上下架生效。
|
||||
5. 内容审核和举报处置生效,通知与审计记录正确。
|
||||
6. 停止 Cultivate Server 后,Manager 仍保持健康,仅养成页面提示“养成服务暂不可用”。
|
||||
7. Manager Pod 环境变量和 Secret 中不存在 Cultivate 数据库 DSN。
|
||||
|
||||
## 18. 交付清单
|
||||
|
||||
Cultivate Server 开发完成后需提供:
|
||||
|
||||
- 接口代码及单元测试。
|
||||
- OpenAPI 或等价接口定义。
|
||||
- 内部 HMAC 鉴权说明。
|
||||
- 测试环境服务地址。
|
||||
- 联调使用的服务名和 Secret 键名,不提供明文 Secret。
|
||||
- 数据迁移说明,如无迁移需明确说明。
|
||||
- 一份 curl 签名测试脚本或 Go 测试客户端。
|
||||
|
||||
上述内容齐备后,再开始 Manager 侧切换和删除直连代码。
|
||||
@@ -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');
|
||||
@@ -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,25 @@
|
||||
package cultivateapprelease
|
||||
|
||||
import (
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type ListReq struct {
|
||||
g.Meta `path:"/cultivate/app-release/list" method:"get" tags:"养成版本更新" summary:"获取养成客户端版本策略列表"`
|
||||
sysin.ImAppReleaseListInp
|
||||
}
|
||||
|
||||
type ListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.ImAppReleaseModel `json:"list" dc:"数据列表"`
|
||||
}
|
||||
|
||||
type EditReq struct {
|
||||
g.Meta `path:"/cultivate/app-release/edit" method:"post" tags:"养成版本更新" summary:"新增/编辑养成客户端版本策略"`
|
||||
sysin.ImAppReleaseEditInp
|
||||
}
|
||||
|
||||
type EditRes struct{}
|
||||
@@ -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{}
|
||||
@@ -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租户"`
|
||||
@@ -31,8 +35,6 @@ type TenantModel struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
TenantDesc *string `json:"tenant_desc,omitempty"`
|
||||
DBName string `json:"db_name"`
|
||||
DBDSN string `json:"db_dsn"`
|
||||
State int `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
@@ -58,6 +60,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"`
|
||||
@@ -71,12 +223,14 @@ type TenantLoginIPWhitelistRes struct {
|
||||
|
||||
type AppReleaseLatestReq struct {
|
||||
g.Meta `path:"/internal/im-config/app/releases/latest" method:"get" tags:"IM内部配置" summary:"获取客户端最新版本策略"`
|
||||
AppCode string `json:"app_code" dc:"应用标识,缺省 im"`
|
||||
Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"`
|
||||
Channel string `json:"channel" dc:"渠道"`
|
||||
}
|
||||
|
||||
type AppReleaseLatestRes struct {
|
||||
ID int64 `json:"id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Platform string `json:"platform"`
|
||||
Channel string `json:"channel"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
|
||||
@@ -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{}
|
||||
@@ -27,11 +27,6 @@ type SaveReq struct {
|
||||
type SaveRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
type PublishReq struct {
|
||||
g.Meta `path:"/imSecurityPolicy/publish" method:"post" tags:"IM安全策略" summary:"发布安全策略"`
|
||||
sysin.ImSecurityPolicyPublishInp
|
||||
}
|
||||
type PublishRes struct{}
|
||||
type RestoreDefaultReq struct {
|
||||
g.Meta `path:"/imSecurityPolicy/restoreDefault" method:"post" tags:"IM安全策略" summary:"恢复默认安全策略"`
|
||||
sysin.ImSecurityPolicyRestoreInp
|
||||
@@ -39,6 +34,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package imtenantapplication
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package imtenantapplication
|
||||
|
||||
import (
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ListReq 查询企业注册申请列表
|
||||
type ListReq struct {
|
||||
g.Meta `path:"/imTenantApplication/list" method:"get" tags:"企业注册申请" summary:"获取企业注册申请列表"`
|
||||
sysin.ImTenantApplicationListInp
|
||||
}
|
||||
|
||||
type ListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.ImTenantApplicationListModel `json:"list" dc:"数据列表"`
|
||||
}
|
||||
|
||||
// ViewReq 获取企业注册申请指定信息
|
||||
type ViewReq struct {
|
||||
g.Meta `path:"/imTenantApplication/view" method:"get" tags:"企业注册申请" summary:"获取企业注册申请指定信息"`
|
||||
sysin.ImTenantApplicationViewInp
|
||||
}
|
||||
|
||||
type ViewRes struct {
|
||||
*sysin.ImTenantApplicationViewModel
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package imtenantentryfeedback
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package imtenantentryfeedback
|
||||
|
||||
import (
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ListReq 查询企业入口反馈列表
|
||||
type ListReq struct {
|
||||
g.Meta `path:"/imTenantEntryFeedback/list" method:"get" tags:"企业入口反馈" summary:"获取企业入口反馈列表"`
|
||||
sysin.ImTenantEntryFeedbackListInp
|
||||
}
|
||||
|
||||
type ListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.ImTenantEntryFeedbackListModel `json:"list" dc:"数据列表"`
|
||||
}
|
||||
|
||||
// ViewReq 获取企业入口反馈指定信息
|
||||
type ViewReq struct {
|
||||
g.Meta `path:"/imTenantEntryFeedback/view" method:"get" tags:"企业入口反馈" summary:"获取企业入口反馈指定信息"`
|
||||
sysin.ImTenantEntryFeedbackViewInp
|
||||
}
|
||||
|
||||
type ViewRes struct {
|
||||
*sysin.ImTenantEntryFeedbackViewModel
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package maoyucontent
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
)
|
||||
|
||||
// 毛隅内容治理页面:概览、动态/评论审核、举报、操作日志。
|
||||
// 页面协议与养成内容治理一致,通过 maoyu-server 内部接口处理毛隅业务。
|
||||
|
||||
type MaoyuContentDashboardReq struct {
|
||||
g.Meta `path:"/maoyu/dashboard" method:"get" tags:"毛隅管理" summary:"获取毛隅管理概览"`
|
||||
sysin.MaoyuDashboardInp
|
||||
}
|
||||
type MaoyuContentDashboardRes struct {
|
||||
*sysin.MaoyuDashboardModel
|
||||
}
|
||||
|
||||
type MaoyuContentListReq struct {
|
||||
g.Meta `path:"/maoyu/content/list" method:"get" tags:"毛隅管理" summary:"获取毛隅内容列表"`
|
||||
*sysin.CultivateContentListInp
|
||||
}
|
||||
type MaoyuContentListRes struct {
|
||||
List []*sysin.CultivateContentListModel `json:"list" dc:"内容列表"`
|
||||
form.PageRes
|
||||
}
|
||||
|
||||
type MaoyuContentViewReq struct {
|
||||
g.Meta `path:"/maoyu/content/view" method:"get" tags:"毛隅管理" summary:"获取毛隅内容详情"`
|
||||
*sysin.CultivateContentViewInp
|
||||
}
|
||||
type MaoyuContentViewRes struct {
|
||||
*sysin.CultivateContentViewModel
|
||||
}
|
||||
|
||||
type MaoyuContentReviewReq struct {
|
||||
g.Meta `path:"/maoyu/content/review" method:"post" tags:"毛隅管理" summary:"毛隅内容单条审核"`
|
||||
*sysin.CultivateContentReviewInp
|
||||
}
|
||||
type MaoyuContentReviewRes struct{}
|
||||
|
||||
type MaoyuContentBatchReviewReq struct {
|
||||
g.Meta `path:"/maoyu/content/batch-review" method:"post" tags:"毛隅管理" summary:"毛隅内容批量审核"`
|
||||
*sysin.CultivateContentBatchReviewInp
|
||||
}
|
||||
type MaoyuContentBatchReviewRes struct {
|
||||
*sysin.CultivateContentBatchReviewModel
|
||||
}
|
||||
|
||||
type MaoyuReportListReq struct {
|
||||
g.Meta `path:"/maoyu/reports/list" method:"get" tags:"毛隅管理" summary:"获取毛隅举报列表"`
|
||||
*sysin.CultivateReportListInp
|
||||
}
|
||||
type MaoyuReportListRes struct {
|
||||
List []*sysin.CultivateReportListModel `json:"list" dc:"举报列表"`
|
||||
form.PageRes
|
||||
}
|
||||
|
||||
type MaoyuReportViewReq struct {
|
||||
g.Meta `path:"/maoyu/reports/view" method:"get" tags:"毛隅管理" summary:"获取毛隅举报详情"`
|
||||
*sysin.CultivateReportViewInp
|
||||
}
|
||||
type MaoyuReportViewRes struct {
|
||||
*sysin.CultivateReportViewModel
|
||||
}
|
||||
|
||||
type MaoyuReportHandleReq struct {
|
||||
g.Meta `path:"/maoyu/reports/handle" method:"post" tags:"毛隅管理" summary:"处理毛隅举报"`
|
||||
*sysin.CultivateReportHandleInp
|
||||
}
|
||||
type MaoyuReportHandleRes struct{}
|
||||
|
||||
type MaoyuLogListReq struct {
|
||||
g.Meta `path:"/maoyu/logs/list" method:"get" tags:"毛隅管理" summary:"获取毛隅操作日志列表"`
|
||||
*sysin.CultivateOperationLogListInp
|
||||
}
|
||||
type MaoyuLogListRes struct {
|
||||
List []*sysin.CultivateOperationLogListModel `json:"list" dc:"操作日志列表"`
|
||||
form.PageRes
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package maoyupet
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
)
|
||||
|
||||
// 毛隅萌宠管理页面:互动动作配置、宠物档案、场景生成任务、互动通知。
|
||||
|
||||
type MaoyuActionConfigListReq struct {
|
||||
g.Meta `path:"/maoyu/action-configs/list" method:"get" tags:"毛隅管理" summary:"获取毛隅动作配置列表"`
|
||||
*sysin.MaoyuActionConfigListInp
|
||||
}
|
||||
type MaoyuActionConfigListRes struct {
|
||||
List []*sysin.MaoyuActionConfigListModel `json:"list" dc:"动作配置列表"`
|
||||
form.PageRes
|
||||
}
|
||||
|
||||
type MaoyuActionConfigViewReq struct {
|
||||
g.Meta `path:"/maoyu/action-configs/view" method:"get" tags:"毛隅管理" summary:"获取毛隅动作配置详情"`
|
||||
*sysin.MaoyuActionConfigViewInp
|
||||
}
|
||||
type MaoyuActionConfigViewRes struct {
|
||||
*sysin.MaoyuActionConfigViewModel
|
||||
}
|
||||
|
||||
type MaoyuActionConfigEditReq struct {
|
||||
g.Meta `path:"/maoyu/action-configs/edit" method:"post" tags:"毛隅管理" summary:"新增或编辑毛隅动作配置"`
|
||||
*sysin.MaoyuActionConfigEditInp
|
||||
}
|
||||
type MaoyuActionConfigEditRes struct{}
|
||||
|
||||
type MaoyuActionConfigStatusReq struct {
|
||||
g.Meta `path:"/maoyu/action-configs/status" method:"post" tags:"毛隅管理" summary:"启用/停用毛隅动作配置"`
|
||||
*sysin.MaoyuActionConfigStatusInp
|
||||
}
|
||||
type MaoyuActionConfigStatusRes struct{}
|
||||
|
||||
type MaoyuPetListReq struct {
|
||||
g.Meta `path:"/maoyu/pets/list" method:"get" tags:"毛隅管理" summary:"获取毛隅宠物档案列表"`
|
||||
*sysin.MaoyuPetListInp
|
||||
}
|
||||
type MaoyuPetListRes struct {
|
||||
List []*sysin.MaoyuPetListModel `json:"list" dc:"宠物档案列表"`
|
||||
form.PageRes
|
||||
}
|
||||
|
||||
type MaoyuPetViewReq struct {
|
||||
g.Meta `path:"/maoyu/pets/view" method:"get" tags:"毛隅管理" summary:"获取毛隅宠物档案详情"`
|
||||
*sysin.MaoyuPetViewInp
|
||||
}
|
||||
type MaoyuPetViewRes struct {
|
||||
*sysin.MaoyuPetViewModel
|
||||
}
|
||||
|
||||
type MaoyuJobListReq struct {
|
||||
g.Meta `path:"/maoyu/generation-jobs/list" method:"get" tags:"毛隅管理" summary:"获取毛隅生成任务列表"`
|
||||
*sysin.MaoyuJobListInp
|
||||
}
|
||||
type MaoyuJobListRes struct {
|
||||
List []*sysin.MaoyuJobListModel `json:"list" dc:"生成任务列表"`
|
||||
form.PageRes
|
||||
}
|
||||
|
||||
type MaoyuJobViewReq struct {
|
||||
g.Meta `path:"/maoyu/generation-jobs/view" method:"get" tags:"毛隅管理" summary:"获取毛隅生成任务详情"`
|
||||
*sysin.MaoyuJobViewInp
|
||||
}
|
||||
type MaoyuJobViewRes struct {
|
||||
*sysin.MaoyuJobViewModel
|
||||
}
|
||||
|
||||
type MaoyuNoticeListReq struct {
|
||||
g.Meta `path:"/maoyu/notices/list" method:"get" tags:"毛隅管理" summary:"获取毛隅互动通知列表"`
|
||||
*sysin.MaoyuNoticeListInp
|
||||
}
|
||||
type MaoyuNoticeListRes struct {
|
||||
List []*sysin.MaoyuNoticeListModel `json:"list" dc:"互动通知列表"`
|
||||
form.PageRes
|
||||
}
|
||||
+4
-3
@@ -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
@@ -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=
|
||||
|
||||
@@ -34,7 +34,6 @@ gfcli:
|
||||
gJsonSupport: true
|
||||
clear: false
|
||||
|
||||
|
||||
# service: # 生成业务配置
|
||||
# srcFolder: "internal/logic"
|
||||
# dstFolder: "internal/service"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
func init() {
|
||||
dict.RegisterEnums("ImAppPlatformOptions", "IM客户端平台选项", ImAppPlatformOptions)
|
||||
dict.RegisterEnums("ImAppUpdateTypeOptions", "IM客户端更新类型选项", ImAppUpdateTypeOptions)
|
||||
dict.RegisterEnums("ImAppCodeOptions", "IM客户端应用标识选项", ImAppCodeOptions)
|
||||
dict.RegisterEnums("ImAbuseTargetTypeOptions", "IM举报对象类型选项", ImAbuseTargetTypeOptions)
|
||||
dict.RegisterEnums("ImAbuseReasonEnabledOptions", "IM举报原因启用状态", ImAbuseReasonEnabledOptions)
|
||||
}
|
||||
@@ -17,6 +18,11 @@ const (
|
||||
ImAppPlatformAndroid = "android"
|
||||
)
|
||||
|
||||
const (
|
||||
ImAppCodeIm = "im"
|
||||
ImAppCodeGarden = "garden"
|
||||
)
|
||||
|
||||
const (
|
||||
ImAppUpdateTypeNone = "none"
|
||||
ImAppUpdateTypeRecommended = "recommended"
|
||||
@@ -34,6 +40,11 @@ var ImAppPlatformOptions = []*model.Option{
|
||||
dict.GenSuccessOption(ImAppPlatformAndroid, "Android"),
|
||||
}
|
||||
|
||||
var ImAppCodeOptions = []*model.Option{
|
||||
dict.GenPrimaryOption(ImAppCodeIm, "讯工捷使"),
|
||||
dict.GenSuccessOption(ImAppCodeGarden, "禾圃集"),
|
||||
}
|
||||
|
||||
var ImAppUpdateTypeOptions = []*model.Option{
|
||||
dict.GenDefaultOption(ImAppUpdateTypeNone, "无需更新"),
|
||||
dict.GenWarningOption(ImAppUpdateTypeRecommended, "建议更新"),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/api/admin/cultivateapprelease"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
var CultivateAppRelease = cCultivateAppRelease{}
|
||||
|
||||
type cCultivateAppRelease struct{}
|
||||
|
||||
func (c *cCultivateAppRelease) List(ctx context.Context, req *cultivateapprelease.ListReq) (res *cultivateapprelease.ListRes, err error) {
|
||||
list, totalCount, err := service.SysImAppRelease().List(ctx, &req.ImAppReleaseListInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.ImAppReleaseModel{}
|
||||
}
|
||||
res = new(cultivateapprelease.ListRes)
|
||||
res.List = list
|
||||
res.PageRes.Pack(req, totalCount)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cCultivateAppRelease) Edit(ctx context.Context, req *cultivateapprelease.EditReq) (res *cultivateapprelease.EditRes, err error) {
|
||||
err = service.SysImAppRelease().Edit(ctx, &req.ImAppReleaseEditInp)
|
||||
return
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -38,10 +38,6 @@ func (c *cImSecurityPolicy) Save(ctx context.Context, req *imsecuritypolicy.Save
|
||||
}
|
||||
return &imsecuritypolicy.SaveRes{Id: id}, nil
|
||||
}
|
||||
func (c *cImSecurityPolicy) Publish(ctx context.Context, req *imsecuritypolicy.PublishReq) (res *imsecuritypolicy.PublishRes, err error) {
|
||||
err = service.SysImSecurityPolicy().Publish(ctx, &req.ImSecurityPolicyPublishInp)
|
||||
return
|
||||
}
|
||||
func (c *cImSecurityPolicy) RestoreDefault(ctx context.Context, req *imsecuritypolicy.RestoreDefaultReq) (res *imsecuritypolicy.RestoreDefaultRes, err error) {
|
||||
id, err := service.SysImSecurityPolicy().RestoreDefault(ctx, &req.ImSecurityPolicyRestoreInp)
|
||||
if err != nil {
|
||||
@@ -49,6 +45,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)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package sys
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/api/admin/imtenantapplication"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ImTenantApplication = cImTenantApplication{}
|
||||
)
|
||||
|
||||
type cImTenantApplication struct{}
|
||||
|
||||
// List 查看企业注册申请列表
|
||||
func (c *cImTenantApplication) List(ctx context.Context, req *imtenantapplication.ListReq) (res *imtenantapplication.ListRes, err error) {
|
||||
list, totalCount, err := service.SysImTenantApplication().List(ctx, &req.ImTenantApplicationListInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.ImTenantApplicationListModel{}
|
||||
}
|
||||
|
||||
res = new(imtenantapplication.ListRes)
|
||||
res.List = list
|
||||
res.PageRes.Pack(req, totalCount)
|
||||
return
|
||||
}
|
||||
|
||||
// View 获取指定企业注册申请信息
|
||||
func (c *cImTenantApplication) View(ctx context.Context, req *imtenantapplication.ViewReq) (res *imtenantapplication.ViewRes, err error) {
|
||||
data, err := service.SysImTenantApplication().View(ctx, &req.ImTenantApplicationViewInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res = new(imtenantapplication.ViewRes)
|
||||
res.ImTenantApplicationViewModel = data
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package sys
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/api/admin/imtenantentryfeedback"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ImTenantEntryFeedback = cImTenantEntryFeedback{}
|
||||
)
|
||||
|
||||
type cImTenantEntryFeedback struct{}
|
||||
|
||||
// List 查看企业入口反馈列表
|
||||
func (c *cImTenantEntryFeedback) List(ctx context.Context, req *imtenantentryfeedback.ListReq) (res *imtenantentryfeedback.ListRes, err error) {
|
||||
list, totalCount, err := service.SysImTenantEntryFeedback().List(ctx, &req.ImTenantEntryFeedbackListInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.ImTenantEntryFeedbackListModel{}
|
||||
}
|
||||
|
||||
res = new(imtenantentryfeedback.ListRes)
|
||||
res.List = list
|
||||
res.PageRes.Pack(req, totalCount)
|
||||
return
|
||||
}
|
||||
|
||||
// View 获取指定企业入口反馈信息
|
||||
func (c *cImTenantEntryFeedback) View(ctx context.Context, req *imtenantentryfeedback.ViewReq) (res *imtenantentryfeedback.ViewRes, err error) {
|
||||
data, err := service.SysImTenantEntryFeedback().View(ctx, &req.ImTenantEntryFeedbackViewInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res = new(imtenantentryfeedback.ViewRes)
|
||||
res.ImTenantEntryFeedbackViewModel = data
|
||||
return
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"hotgo/api/admin/maoyucontent"
|
||||
"hotgo/api/admin/maoyupet"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
// 毛隅管理页面控制器:内容治理 + 萌宠管理,业务经 maoyu-server 内部接口处理。
|
||||
|
||||
var (
|
||||
MaoyuContent = cMaoyuContent{}
|
||||
MaoyuPet = cMaoyuPet{}
|
||||
)
|
||||
|
||||
type (
|
||||
cMaoyuContent struct{}
|
||||
cMaoyuPet struct{}
|
||||
)
|
||||
|
||||
// ---- 内容治理 ----
|
||||
|
||||
func (*cMaoyuContent) Dashboard(ctx context.Context, req *maoyucontent.MaoyuContentDashboardReq) (res *maoyucontent.MaoyuContentDashboardRes, err error) {
|
||||
data, err := service.SysMaoyuContent().Dashboard(ctx, &req.MaoyuDashboardInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyucontent.MaoyuContentDashboardRes{MaoyuDashboardModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) List(ctx context.Context, req *maoyucontent.MaoyuContentListReq) (res *maoyucontent.MaoyuContentListRes, err error) {
|
||||
list, total, err := service.SysMaoyuContent().List(ctx, req.CultivateContentListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.CultivateContentListModel{}
|
||||
}
|
||||
res = &maoyucontent.MaoyuContentListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) View(ctx context.Context, req *maoyucontent.MaoyuContentViewReq) (res *maoyucontent.MaoyuContentViewRes, err error) {
|
||||
data, err := service.SysMaoyuContent().View(ctx, req.CultivateContentViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyucontent.MaoyuContentViewRes{CultivateContentViewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) Review(ctx context.Context, req *maoyucontent.MaoyuContentReviewReq) (res *maoyucontent.MaoyuContentReviewRes, err error) {
|
||||
err = service.SysMaoyuContent().Review(ctx, req.CultivateContentReviewInp)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) BatchReview(ctx context.Context, req *maoyucontent.MaoyuContentBatchReviewReq) (res *maoyucontent.MaoyuContentBatchReviewRes, err error) {
|
||||
data, err := service.SysMaoyuContent().BatchReview(ctx, req.CultivateContentBatchReviewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyucontent.MaoyuContentBatchReviewRes{CultivateContentBatchReviewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) ReportList(ctx context.Context, req *maoyucontent.MaoyuReportListReq) (res *maoyucontent.MaoyuReportListRes, err error) {
|
||||
list, total, err := service.SysMaoyuContent().ReportList(ctx, req.CultivateReportListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.CultivateReportListModel{}
|
||||
}
|
||||
res = &maoyucontent.MaoyuReportListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) ReportView(ctx context.Context, req *maoyucontent.MaoyuReportViewReq) (res *maoyucontent.MaoyuReportViewRes, err error) {
|
||||
data, err := service.SysMaoyuContent().ReportView(ctx, req.CultivateReportViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyucontent.MaoyuReportViewRes{CultivateReportViewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) ReportHandle(ctx context.Context, req *maoyucontent.MaoyuReportHandleReq) (res *maoyucontent.MaoyuReportHandleRes, err error) {
|
||||
err = service.SysMaoyuContent().ReportHandle(ctx, req.CultivateReportHandleInp)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuContent) LogList(ctx context.Context, req *maoyucontent.MaoyuLogListReq) (res *maoyucontent.MaoyuLogListRes, err error) {
|
||||
list, total, err := service.SysMaoyuContent().LogList(ctx, req.CultivateOperationLogListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.CultivateOperationLogListModel{}
|
||||
}
|
||||
res = &maoyucontent.MaoyuLogListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
// ---- 萌宠管理 ----
|
||||
|
||||
func (*cMaoyuPet) ActionConfigList(ctx context.Context, req *maoyupet.MaoyuActionConfigListReq) (res *maoyupet.MaoyuActionConfigListRes, err error) {
|
||||
list, total, err := service.SysMaoyuPet().ActionConfigList(ctx, req.MaoyuActionConfigListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.MaoyuActionConfigListModel{}
|
||||
}
|
||||
res = &maoyupet.MaoyuActionConfigListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) ActionConfigView(ctx context.Context, req *maoyupet.MaoyuActionConfigViewReq) (res *maoyupet.MaoyuActionConfigViewRes, err error) {
|
||||
data, err := service.SysMaoyuPet().ActionConfigView(ctx, req.MaoyuActionConfigViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyupet.MaoyuActionConfigViewRes{MaoyuActionConfigViewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) ActionConfigEdit(ctx context.Context, req *maoyupet.MaoyuActionConfigEditReq) (res *maoyupet.MaoyuActionConfigEditRes, err error) {
|
||||
err = service.SysMaoyuPet().ActionConfigEdit(ctx, req.MaoyuActionConfigEditInp)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) ActionConfigStatus(ctx context.Context, req *maoyupet.MaoyuActionConfigStatusReq) (res *maoyupet.MaoyuActionConfigStatusRes, err error) {
|
||||
err = service.SysMaoyuPet().ActionConfigStatus(ctx, req.MaoyuActionConfigStatusInp)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) PetList(ctx context.Context, req *maoyupet.MaoyuPetListReq) (res *maoyupet.MaoyuPetListRes, err error) {
|
||||
list, total, err := service.SysMaoyuPet().PetList(ctx, req.MaoyuPetListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.MaoyuPetListModel{}
|
||||
}
|
||||
res = &maoyupet.MaoyuPetListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) PetView(ctx context.Context, req *maoyupet.MaoyuPetViewReq) (res *maoyupet.MaoyuPetViewRes, err error) {
|
||||
data, err := service.SysMaoyuPet().PetView(ctx, req.MaoyuPetViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyupet.MaoyuPetViewRes{MaoyuPetViewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) JobList(ctx context.Context, req *maoyupet.MaoyuJobListReq) (res *maoyupet.MaoyuJobListRes, err error) {
|
||||
list, total, err := service.SysMaoyuPet().JobList(ctx, req.MaoyuJobListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.MaoyuJobListModel{}
|
||||
}
|
||||
res = &maoyupet.MaoyuJobListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) JobView(ctx context.Context, req *maoyupet.MaoyuJobViewReq) (res *maoyupet.MaoyuJobViewRes, err error) {
|
||||
data, err := service.SysMaoyuPet().JobView(ctx, req.MaoyuJobViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maoyupet.MaoyuJobViewRes{MaoyuJobViewModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cMaoyuPet) NoticeList(ctx context.Context, req *maoyupet.MaoyuNoticeListReq) (res *maoyupet.MaoyuNoticeListRes, err error) {
|
||||
list, total, err := service.SysMaoyuPet().NoticeList(ctx, req.MaoyuNoticeListInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.MaoyuNoticeListModel{}
|
||||
}
|
||||
res = &maoyupet.MaoyuNoticeListRes{List: list}
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
@@ -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,355 @@ 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)
|
||||
res.AliyunEnabled = tenant.AliyunModerationEnabled == 1
|
||||
res.LocalWordsEnabled = tenant.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)
|
||||
if tenant.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
|
||||
@@ -103,8 +468,15 @@ func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLog
|
||||
}
|
||||
|
||||
func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLatestReq) (res *api.AppReleaseLatestRes, err error) {
|
||||
appCode := strings.ToLower(strings.TrimSpace(req.AppCode))
|
||||
platform := strings.ToLower(strings.TrimSpace(req.Platform))
|
||||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
if appCode == "" {
|
||||
appCode = "im"
|
||||
}
|
||||
if appCode != "im" && appCode != "garden" {
|
||||
return nil, gerror.New("应用标识不合法")
|
||||
}
|
||||
if platform != "ios" && platform != "android" {
|
||||
return nil, gerror.New("客户端平台不合法")
|
||||
}
|
||||
@@ -116,6 +488,7 @@ func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLates
|
||||
}
|
||||
var row struct {
|
||||
ID int64 `json:"id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Platform string `json:"platform"`
|
||||
Channel string `json:"channel"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
@@ -131,6 +504,7 @@ func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLates
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
err = imtenant.RegistryDB().Model("im_app_releases").Safe().Ctx(ctx).
|
||||
Where("app_code", appCode).
|
||||
Where("platform", platform).Where("channel", channel).Limit(1).Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取IM客户端版本策略失败")
|
||||
@@ -139,7 +513,7 @@ func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLates
|
||||
return nil, gerror.New("IM客户端版本策略不存在")
|
||||
}
|
||||
return &api.AppReleaseLatestRes{
|
||||
ID: row.ID, Platform: row.Platform, Channel: row.Channel,
|
||||
ID: row.ID, AppCode: row.AppCode, Platform: row.Platform, Channel: row.Channel,
|
||||
LatestVersion: row.LatestVersion, MinSupportedVersion: row.MinSupportedVersion,
|
||||
UpdateType: row.UpdateType, ReleaseNotes: row.ReleaseNotes, StoreURL: row.StoreURL,
|
||||
PackageURL: row.PackageURL, Checksum: row.Checksum, RemindAfterSeconds: row.RemindAfterSeconds,
|
||||
@@ -168,8 +542,6 @@ func tenantToModel(tenant *entity.ImTenant) *api.TenantModel {
|
||||
Code: tenant.Code,
|
||||
Name: tenant.Name,
|
||||
TenantDesc: &desc,
|
||||
DBName: tenant.DbName,
|
||||
DBDSN: tenant.DbDsn,
|
||||
State: tenant.State,
|
||||
CreatedAt: gtimeToRFC3339(tenant.CreatedAt),
|
||||
CreatedBy: tenant.CreatedBy,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"hotgo/internal/dao/internal"
|
||||
)
|
||||
|
||||
// imTenantConfigDao is the data access object for the table im_tenant_config.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type imTenantConfigDao struct {
|
||||
*internal.ImTenantConfigDao
|
||||
}
|
||||
|
||||
var (
|
||||
// ImTenantConfig is a globally accessible object for table im_tenant_config operations.
|
||||
ImTenantConfig = imTenantConfigDao{internal.NewImTenantConfigDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -21,32 +21,50 @@ type ImTenantDao struct {
|
||||
|
||||
// ImTenantColumns defines and stores column names for the table im_tenant.
|
||||
type ImTenantColumns struct {
|
||||
Id string //
|
||||
Code string //
|
||||
Name string //
|
||||
TenantDesc string //
|
||||
DbName string //
|
||||
DbDsn string //
|
||||
State string //
|
||||
CreatedAt string //
|
||||
CreatedBy string //
|
||||
UpdatedAt string //
|
||||
UpdatedBy string //
|
||||
Id string //
|
||||
Code string //
|
||||
Name string //
|
||||
TenantDesc string //
|
||||
MaxUsers string // 0表示ä¸é™åˆ¶
|
||||
MaxGroups string // 0表示ä¸é™åˆ¶
|
||||
MaxGroupMembers string // 0表示ä¸é™åˆ¶
|
||||
RealnameReviewPolicy string // 实åå®¡æ ¸ç–略:0自动通过,1è½¬äººå·¥å®¡æ ¸
|
||||
AliyunModerationEnabled string // 是å¦å¯ç”¨é˜¿é‡Œäº‘å†…å®¹å®¡æ ¸
|
||||
LocalModerationEnabled string // 是å¦å¯ç”¨æœ¬åœ°å†…å®¹å®¡æ ¸
|
||||
MessageEncryptionEnabled string // 是å¦å¯ç”¨æ¶ˆæ¯åР坆
|
||||
DefaultPolicyGroupId string // 默认用户ç–略组
|
||||
SecurityPolicyVersion string // å·²å‘布安全ç–略版本
|
||||
GlobalFriendEnabled string // å…¨å±€åŠ å¥½å‹å¼€å…³
|
||||
MessageCurfewEnabled string // 消æ¯å®µç¦å¼€å…³
|
||||
State string //
|
||||
CreatedAt string //
|
||||
CreatedBy string //
|
||||
UpdatedAt string //
|
||||
UpdatedBy string //
|
||||
}
|
||||
|
||||
// imTenantColumns holds the columns for the table im_tenant.
|
||||
var imTenantColumns = ImTenantColumns{
|
||||
Id: "id",
|
||||
Code: "code",
|
||||
Name: "name",
|
||||
TenantDesc: "tenant_desc",
|
||||
DbName: "db_name",
|
||||
DbDsn: "db_dsn",
|
||||
State: "state",
|
||||
CreatedAt: "created_at",
|
||||
CreatedBy: "created_by",
|
||||
UpdatedAt: "updated_at",
|
||||
UpdatedBy: "updated_by",
|
||||
Id: "id",
|
||||
Code: "code",
|
||||
Name: "name",
|
||||
TenantDesc: "tenant_desc",
|
||||
MaxUsers: "max_users",
|
||||
MaxGroups: "max_groups",
|
||||
MaxGroupMembers: "max_group_members",
|
||||
RealnameReviewPolicy: "realname_review_policy",
|
||||
AliyunModerationEnabled: "aliyun_moderation_enabled",
|
||||
LocalModerationEnabled: "local_moderation_enabled",
|
||||
MessageEncryptionEnabled: "message_encryption_enabled",
|
||||
DefaultPolicyGroupId: "default_policy_group_id",
|
||||
SecurityPolicyVersion: "security_policy_version",
|
||||
GlobalFriendEnabled: "global_friend_enabled",
|
||||
MessageCurfewEnabled: "message_curfew_enabled",
|
||||
State: "state",
|
||||
CreatedAt: "created_at",
|
||||
CreatedBy: "created_by",
|
||||
UpdatedAt: "updated_at",
|
||||
UpdatedBy: "updated_by",
|
||||
}
|
||||
|
||||
// NewImTenantDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// ==========================================================================
|
||||
// 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"
|
||||
)
|
||||
|
||||
// ImTenantConfigDao is the data access object for the table im_tenant_config.
|
||||
type ImTenantConfigDao 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 ImTenantConfigColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// ImTenantConfigColumns defines and stores column names for the table im_tenant_config.
|
||||
type ImTenantConfigColumns struct {
|
||||
Id string //
|
||||
TenantId string //
|
||||
MaxUsers string // 0 means unlimited
|
||||
MaxGroups string // 0 means unlimited
|
||||
MaxGroupMembers string // 0 means unlimited
|
||||
DefaultPolicyGroupId string // Default user policy group
|
||||
SecurityPolicyVersion string // Published security policy version
|
||||
GlobalFriendEnabled string // Published global friend switch
|
||||
MessageCurfewEnabled string // Published message curfew switch
|
||||
CreatedAt string //
|
||||
CreatedBy string //
|
||||
UpdatedAt string //
|
||||
UpdatedBy string //
|
||||
}
|
||||
|
||||
// imTenantConfigColumns holds the columns for the table im_tenant_config.
|
||||
var imTenantConfigColumns = ImTenantConfigColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
MaxUsers: "max_users",
|
||||
MaxGroups: "max_groups",
|
||||
MaxGroupMembers: "max_group_members",
|
||||
DefaultPolicyGroupId: "default_policy_group_id",
|
||||
SecurityPolicyVersion: "security_policy_version",
|
||||
GlobalFriendEnabled: "global_friend_enabled",
|
||||
MessageCurfewEnabled: "message_curfew_enabled",
|
||||
CreatedAt: "created_at",
|
||||
CreatedBy: "created_by",
|
||||
UpdatedAt: "updated_at",
|
||||
UpdatedBy: "updated_by",
|
||||
}
|
||||
|
||||
// NewImTenantConfigDao creates and returns a new DAO object for table data access.
|
||||
func NewImTenantConfigDao(handlers ...gdb.ModelHandler) *ImTenantConfigDao {
|
||||
return &ImTenantConfigDao{
|
||||
group: "default",
|
||||
table: "im_tenant_config",
|
||||
columns: imTenantConfigColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *ImTenantConfigDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *ImTenantConfigDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *ImTenantConfigDao) Columns() ImTenantConfigColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *ImTenantConfigDao) 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 *ImTenantConfigDao) 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 *ImTenantConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -10,13 +10,18 @@ import (
|
||||
"fmt"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/cache"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
"github.com/casbin/casbin/v2/model"
|
||||
casbinutil "github.com/casbin/casbin/v2/util"
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gfile"
|
||||
"github.com/gogf/gf/v2/os/gres"
|
||||
@@ -33,6 +38,14 @@ const (
|
||||
|
||||
var Enforcer *casbin.Enforcer
|
||||
|
||||
const permissionCacheKey = "admin:role:permissions"
|
||||
|
||||
type PermissionPolicy struct {
|
||||
Key string `json:"key"`
|
||||
Perm string `json:"perm"`
|
||||
ActRegexp string `json:"actRegexp"`
|
||||
}
|
||||
|
||||
// InitEnforcer 初始化
|
||||
func InitEnforcer(ctx context.Context) {
|
||||
var (
|
||||
@@ -69,6 +82,9 @@ func InitEnforcer(ctx context.Context) {
|
||||
g.Log().Panicf(ctx, "casbin NewEnforcer err:%v", err)
|
||||
}
|
||||
loadPermissions(ctx)
|
||||
if err = RefreshPermissionCache(ctx); err != nil {
|
||||
g.Log().Panicf(ctx, "casbin RefreshPermissionCache err:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDbLink 获取数据库链接
|
||||
@@ -91,15 +107,13 @@ func getDbLink(ctx context.Context) *gvar.Var {
|
||||
|
||||
}
|
||||
|
||||
func loadPermissions(ctx context.Context) {
|
||||
type Policy struct {
|
||||
func queryPermissionPolicies(ctx context.Context) (policies []*PermissionPolicy, err error) {
|
||||
type RolePermission struct {
|
||||
Key string `json:"key"`
|
||||
Permissions string `json:"permissions"`
|
||||
}
|
||||
var (
|
||||
rules [][]string
|
||||
polices []*Policy
|
||||
err error
|
||||
records []*RolePermission
|
||||
)
|
||||
//别名拼接 r.key m.permissions
|
||||
q := func(alias string, column string) string {
|
||||
@@ -113,23 +127,43 @@ func loadPermissions(ctx context.Context) {
|
||||
Where(q("m", dao.AdminMenu.Columns().Status), consts.StatusEnabled).
|
||||
WhereNot(q("m", dao.AdminMenu.Columns().Permissions), "").
|
||||
WhereNot(q("r", dao.AdminRole.Columns().Key), consts.SuperRoleKey).
|
||||
Scan(&polices)
|
||||
Scan(&records)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
if strings.Contains(record.Permissions, ",") {
|
||||
lst := strings.Split(record.Permissions, ",")
|
||||
for _, permission := range lst {
|
||||
permission = strings.TrimSpace(permission)
|
||||
if permission == "" {
|
||||
continue
|
||||
}
|
||||
policies = append(policies, &PermissionPolicy{Key: record.Key, Perm: permission, ActRegexp: ActionAll})
|
||||
}
|
||||
} else {
|
||||
permission := strings.TrimSpace(record.Permissions)
|
||||
if permission == "" {
|
||||
continue
|
||||
}
|
||||
policies = append(policies, &PermissionPolicy{Key: record.Key, Perm: permission, ActRegexp: ActionAll})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func loadPermissions(ctx context.Context) {
|
||||
policies, err := queryPermissionPolicies(ctx)
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "loadPermissions Scan err:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, policy := range polices {
|
||||
if strings.Contains(policy.Permissions, ",") {
|
||||
lst := strings.Split(policy.Permissions, ",")
|
||||
for _, permissions := range lst {
|
||||
rules = append(rules, []string{policy.Key, permissions, ActionAll})
|
||||
}
|
||||
} else {
|
||||
rules = append(rules, []string{policy.Key, policy.Permissions, ActionAll})
|
||||
}
|
||||
rules := make([][]string, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
rules = append(rules, []string{policy.Key, policy.Perm, policy.ActRegexp})
|
||||
}
|
||||
|
||||
if _, err = Enforcer.AddPolicies(rules); err != nil {
|
||||
g.Log().Fatalf(ctx, "loadPermissions AddPolicies err:%v", err)
|
||||
return
|
||||
@@ -160,5 +194,65 @@ func Refresh(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
loadPermissions(ctx)
|
||||
return RefreshPermissionCache(ctx)
|
||||
}
|
||||
|
||||
func RefreshPermissionCache(ctx context.Context) (err error) {
|
||||
policies, err := queryPermissionPolicies(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rolePolicies := make(map[string][]*PermissionPolicy)
|
||||
for _, policy := range policies {
|
||||
rolePolicies[policy.Key] = append(rolePolicies[policy.Key], policy)
|
||||
}
|
||||
data, err := gjson.Encode(rolePolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cache.Instance().Set(ctx, permissionCacheKey, string(data), 30*24*time.Hour)
|
||||
}
|
||||
|
||||
func cachedPermissionPolicies(ctx context.Context) (rolePolicies map[string][]*PermissionPolicy, err error) {
|
||||
value, err := cache.Instance().Get(ctx, permissionCacheKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.IsNil() {
|
||||
if err = RefreshPermissionCache(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err = cache.Instance().Get(ctx, permissionCacheKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rolePolicies = make(map[string][]*PermissionPolicy)
|
||||
if value.IsNil() {
|
||||
return rolePolicies, nil
|
||||
}
|
||||
if err = gjson.DecodeTo([]byte(value.String()), &rolePolicies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Verify(ctx context.Context, roleKey, path, method string) (bool, error) {
|
||||
rolePolicies, err := cachedPermissionPolicies(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, policy := range rolePolicies[roleKey] {
|
||||
if !casbinutil.KeyMatch(path, policy.Perm) {
|
||||
continue
|
||||
}
|
||||
ok, err := regexp.MatchString(policy.ActRegexp, method)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package cultivateadmin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "http://cultivate-server.cultivate-system.svc.cluster.local:8080"
|
||||
maxBodySize = 10 << 20
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
ServiceName string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Scope struct {
|
||||
TenantID int64
|
||||
TenantCode string
|
||||
OperatorID int64
|
||||
RoleKey string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
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:"perPage"`
|
||||
}
|
||||
|
||||
func DefaultClient(ctx context.Context) *Client {
|
||||
timeout := g.Cfg().MustGet(ctx, "cultivateAdminApi.timeout", "5s").Duration()
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
baseURL := resolveValue(g.Cfg().MustGet(ctx, "cultivateAdminApi.baseUrl", defaultBaseURL).String())
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
secret := firstValue(
|
||||
g.Cfg().MustGet(ctx, "cultivateAdminApi.serviceSecret").String(),
|
||||
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"),
|
||||
)
|
||||
return NewClient(Config{
|
||||
BaseURL: baseURL,
|
||||
ServiceName: firstValue(g.Cfg().MustGet(ctx, "cultivateAdminApi.serviceName", "im-manager").String(), "im-manager"),
|
||||
Secret: secret,
|
||||
Timeout: timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func NewClient(conf Config) *Client {
|
||||
return &Client{conf: conf, http: &http.Client{Timeout: conf.Timeout}}
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, scope Scope, path string, values url.Values, out any) error {
|
||||
if values != nil {
|
||||
if query := values.Encode(); query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodGet, scope, path, nil, out)
|
||||
}
|
||||
|
||||
func (c *Client) Post(ctx context.Context, scope Scope, path string, in any, out any) error {
|
||||
return c.write(ctx, http.MethodPost, scope, path, in, out)
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, scope Scope, path string, in any, out any) error {
|
||||
return c.write(ctx, http.MethodPut, scope, path, in, out)
|
||||
}
|
||||
|
||||
func (c *Client) write(ctx context.Context, method string, scope Scope, path string, in any, out any) error {
|
||||
body, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "序列化养成服务请求失败")
|
||||
}
|
||||
return c.doJSON(ctx, method, scope, path, body, out)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method string, scope Scope, path string, body []byte, out any) error {
|
||||
req, err := c.newRequest(ctx, method, scope, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "Cultivate admin request failed url:%s err:%+v", req.URL.String(), err)
|
||||
return gerror.New("养成服务暂不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取养成服务响应失败")
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &envelope); err != nil {
|
||||
g.Log().Warningf(ctx, "Cultivate admin invalid response status:%d requestId:%s", resp.StatusCode, scope.RequestID)
|
||||
return gerror.New("养成服务返回异常")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || envelope.Code != 0 {
|
||||
return cultivateError(ctx, resp.StatusCode, envelope.Reason, envelope.Message, envelope.RequestID)
|
||||
}
|
||||
if out == nil || len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
if err = unmarshalSnakeCase(envelope.Data, out); err != nil {
|
||||
return gerror.Wrap(err, "解析养成服务响应失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method string, scope Scope, path string, body []byte) (*http.Request, error) {
|
||||
baseURL := strings.TrimRight(resolveValue(c.conf.BaseURL), "/")
|
||||
secret := resolveValue(c.conf.Secret)
|
||||
if baseURL == "" {
|
||||
return nil, gerror.New("养成服务地址未配置")
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, gerror.New("养成服务内部鉴权密钥未配置")
|
||||
}
|
||||
if scope.TenantID <= 0 || strings.TrimSpace(scope.TenantCode) == "" || scope.OperatorID <= 0 {
|
||||
return nil, gerror.New("养成服务管理范围不正确")
|
||||
}
|
||||
requestID := strings.TrimSpace(scope.RequestID)
|
||||
if requestID == "" {
|
||||
requestID = strings.TrimSpace(gctx.CtxId(ctx))
|
||||
}
|
||||
nonce, err := randomNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = "manager-" + nonce
|
||||
}
|
||||
prefix := "/internal/admin/cultivate/tenants/" + url.PathEscape(strings.TrimSpace(scope.TenantCode))
|
||||
req, err := http.NewRequestWithContext(ctx, method, baseURL+prefix+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "创建养成服务请求失败")
|
||||
}
|
||||
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||
tenantID := strconv.FormatInt(scope.TenantID, 10)
|
||||
operatorID := strconv.FormatInt(scope.OperatorID, 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{
|
||||
method,
|
||||
req.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestamp,
|
||||
nonce,
|
||||
tenantID,
|
||||
operatorID,
|
||||
scope.RoleKey,
|
||||
requestID,
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Name", firstValue(c.conf.ServiceName, "im-manager"))
|
||||
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-Tenant-ID", tenantID)
|
||||
req.Header.Set("X-Operator-ID", operatorID)
|
||||
req.Header.Set("X-Role-Key", scope.RoleKey)
|
||||
req.Header.Set("X-Request-ID", requestID)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func cultivateError(ctx context.Context, status int, reason, message, requestID string) error {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
g.Log().Warningf(ctx, "Cultivate admin auth failed status:%d reason:%s requestId:%s", status, reason, requestID)
|
||||
return gerror.New("养成服务内部鉴权失败")
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
g.Log().Warningf(ctx, "Cultivate admin failed status:%d reason:%s requestId:%s", status, reason, requestID)
|
||||
if status == http.StatusServiceUnavailable && message != "" {
|
||||
return gerror.New(message)
|
||||
}
|
||||
return gerror.New("养成服务暂不可用")
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
return gerror.New(message)
|
||||
}
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
return gerror.New(reason)
|
||||
}
|
||||
return gerror.New("养成服务请求失败")
|
||||
}
|
||||
|
||||
func unmarshalSnakeCase(raw []byte, out any) error {
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
converted, err := json.Marshal(convertKeys(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(converted, out)
|
||||
}
|
||||
|
||||
func convertKeys(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
result[snakeToLowerCamel(key)] = convertKeys(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = convertKeys(item)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func snakeToLowerCamel(value string) string {
|
||||
parts := strings.Split(value, "_")
|
||||
if len(parts) == 1 {
|
||||
return value
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(parts[0])
|
||||
for _, part := range parts[1:] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(part)
|
||||
builder.WriteRune(unicode.ToUpper(runes[0]))
|
||||
builder.WriteString(string(runes[1:]))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomNonce() (string, error) {
|
||||
var value [18]byte
|
||||
if _, err := cryptorand.Read(value[:]); err != nil {
|
||||
return "", gerror.Wrap(err, "生成养成服务请求随机数失败")
|
||||
}
|
||||
return hex.EncodeToString(value[:]), nil
|
||||
}
|
||||
|
||||
func firstValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
if resolved := resolveValue(value); resolved != "" {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
|
||||
return strings.TrimSpace(os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")))
|
||||
}
|
||||
if strings.HasPrefix(value, "$") && !strings.Contains(value, "://") {
|
||||
return strings.TrimSpace(os.Getenv(strings.TrimPrefix(value, "$")))
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package cultivateadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientSignsScopeAndConvertsSnakeCase(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.URL.RequestURI() != "/internal/admin/cultivate/tenants/default/dashboard?a=1&b=2" {
|
||||
t.Fatalf("request URI = %q", r.URL.RequestURI())
|
||||
}
|
||||
if r.Header.Get("X-Tenant-ID") != "1" || r.Header.Get("X-Operator-ID") != "14" {
|
||||
t.Fatalf("unexpected scope headers")
|
||||
}
|
||||
hash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
hex.EncodeToString(hash[:]),
|
||||
r.Header.Get("X-Timestamp"),
|
||||
r.Header.Get("X-Nonce"),
|
||||
"1",
|
||||
"14",
|
||||
"imManager",
|
||||
"req-test",
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
t.Fatal("signature mismatch")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{"plant_count": 7, "active_7d": 3},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: secret, Timeout: time.Second})
|
||||
client.http = server.Client()
|
||||
var out struct {
|
||||
PlantCount int64 `json:"plantCount"`
|
||||
Active7d int64 `json:"active7d"`
|
||||
}
|
||||
values := make(url.Values)
|
||||
values["b"] = []string{"2"}
|
||||
values["a"] = []string{"1"}
|
||||
err := client.Get(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RoleKey: "imManager", RequestID: "req-test"}, "/dashboard", values, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.PlantCount != 7 || out.Active7d != 3 {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsBusinessMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"code":40901,"reason":"STATE_CONFLICT","message":"当前状态不允许操作"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: "secret", Timeout: time.Second})
|
||||
client.http = server.Client()
|
||||
err := client.Post(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RequestID: "req-test"}, "/content-reviews/feed_post/1/actions", map[string]any{"action": "approve"}, nil)
|
||||
if err == nil || err.Error() != "当前状态不允许操作" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientGeneratesRequestIDWhenContextHasNone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
if !strings.HasPrefix(requestID, "manager-") || len(requestID) <= len("manager-") {
|
||||
t.Fatalf("unexpected request ID: %q", requestID)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: "secret", Timeout: time.Second})
|
||||
client.http = server.Client()
|
||||
err := client.Get(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14}, "/dashboard", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -20,10 +20,11 @@ import (
|
||||
var tenantDbGroups sync.Map
|
||||
|
||||
const registryDbGroup = "imRegistry"
|
||||
const sharedBusinessDatabase = "meeting_im"
|
||||
|
||||
var registryDbGroupOnce sync.Once
|
||||
|
||||
// DB resolves a tenant registry record and returns the corresponding business DB.
|
||||
// DB resolves a tenant registry record and returns the shared IM business DB.
|
||||
func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
|
||||
if tenantId <= 0 {
|
||||
return nil, nil, gerror.New("租户ID不能为空")
|
||||
@@ -36,27 +37,44 @@ func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant
|
||||
return nil, nil, gerror.New("租户不存在")
|
||||
}
|
||||
|
||||
link, err := tenantLink(ctx, tenant)
|
||||
db, err = SharedDB(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return db, tenant, nil
|
||||
}
|
||||
|
||||
group := fmt.Sprintf("im_tenant_%d_%x", tenant.Id, hash(link))
|
||||
// SharedDB returns the common IM database configured by the default tenant.
|
||||
// Tenant isolation inside this database is implemented by tenant_id predicates
|
||||
// and the three tenant-partitioned high-volume table families.
|
||||
func SharedDB(ctx context.Context) (gdb.DB, error) {
|
||||
defaultLink := defaultDbLink(ctx)
|
||||
if defaultLink == "" {
|
||||
return nil, gerror.New("默认数据库连接未配置,无法连接共享业务库")
|
||||
}
|
||||
link, err := replaceMySQLDatabase(defaultLink, sharedBusinessDatabase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbForLink(link, 0)
|
||||
}
|
||||
|
||||
func dbForLink(link string, ownerID int64) (gdb.DB, error) {
|
||||
group := fmt.Sprintf("im_shared_%d_%x", ownerID, hash(link))
|
||||
if cached, ok := tenantDbGroups.Load(group); ok && cached == link {
|
||||
db, err = gdb.Instance(group)
|
||||
return db, tenant, err
|
||||
return gdb.Instance(group)
|
||||
}
|
||||
|
||||
if err = gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
|
||||
return nil, nil, gerror.Wrap(err, "初始化租户业务库连接失败")
|
||||
if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
|
||||
return nil, gerror.Wrap(err, "初始化共享业务库连接失败")
|
||||
}
|
||||
tenantDbGroups.Store(group, link)
|
||||
|
||||
db, err = gdb.Instance(group)
|
||||
db, err := gdb.Instance(group)
|
||||
if err != nil {
|
||||
return nil, nil, gerror.Wrap(err, "获取租户业务库连接失败")
|
||||
return nil, gerror.Wrap(err, "获取共享业务库连接失败")
|
||||
}
|
||||
return db, tenant, nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// RegistryDB returns the dedicated tenant registry connection, or the default
|
||||
@@ -90,26 +108,6 @@ func ensureRegistryDBGroup() {
|
||||
_ = gdb.SetConfigGroup(registryDbGroup, cloned)
|
||||
}
|
||||
|
||||
func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) {
|
||||
if tenant.DbDsn != "" {
|
||||
return normalizeMySQLTimeOptions(tenant.DbDsn)
|
||||
}
|
||||
if tenant.DbName == "" {
|
||||
return "", gerror.New("租户业务库未配置")
|
||||
}
|
||||
|
||||
defaultLink := defaultDbLink(ctx)
|
||||
if defaultLink == "" {
|
||||
return "", gerror.New("默认数据库连接未配置,无法推导租户业务库连接")
|
||||
}
|
||||
|
||||
link, err := replaceMySQLDatabase(defaultLink, tenant.DbName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func defaultDbLink(ctx context.Context) string {
|
||||
if configs, err := gdb.GetConfigGroup("default"); err == nil && len(configs) > 0 {
|
||||
if configs[0].Link != "" {
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
)
|
||||
|
||||
func TestNormalizeMySQLTimeOptions(t *testing.T) {
|
||||
link, err := normalizeMySQLTimeOptions("user:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4")
|
||||
link, err := normalizeMySQLTimeOptions("user:pass@tcp(127.0.0.1:3306)/meeting_im?charset=utf8mb4")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize raw mysql dsn: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(link, "mysql:user:pass@tcp(127.0.0.1:3306)/tinode?") {
|
||||
if !strings.HasPrefix(link, "mysql:user:pass@tcp(127.0.0.1:3306)/meeting_im?") {
|
||||
t.Fatalf("unexpected normalized link: %s", link)
|
||||
}
|
||||
if !strings.Contains(link, "loc=UTC") || !strings.Contains(link, "parseTime=true") {
|
||||
@@ -20,17 +20,17 @@ func TestNormalizeMySQLTimeOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizeMySQLTimeOptionsRejectsUnsupportedFormat(t *testing.T) {
|
||||
if _, err := normalizeMySQLTimeOptions("postgres://user:pass@127.0.0.1:5432/tinode"); err == nil {
|
||||
if _, err := normalizeMySQLTimeOptions("postgres://user:pass@127.0.0.1:5432/meeting_im"); err == nil {
|
||||
t.Fatal("expected unsupported format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceMySQLDatabaseUsesUTC(t *testing.T) {
|
||||
link, err := replaceMySQLDatabase("mysql:user:pass@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=false&charset=utf8mb4", "tinode")
|
||||
link, err := replaceMySQLDatabase("mysql:user:pass@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=false&charset=utf8mb4", "meeting_im")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"/tinode?", "loc=UTC", "parseTime=true", "charset=utf8mb4"} {
|
||||
for _, expected := range []string{"/meeting_im?", "loc=UTC", "parseTime=true", "charset=utf8mb4"} {
|
||||
if !strings.Contains(link, expected) {
|
||||
t.Fatalf("link %q does not contain %q", link, expected)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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 {
|
||||
return c.GetForTenant(ctx, path, values, 0, "", out)
|
||||
}
|
||||
|
||||
func (c *Client) GetForTenant(ctx context.Context, path string, values url.Values, tenantID int64, tenantCode string, out any) error {
|
||||
if values != nil {
|
||||
if query := values.Encode(); query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodGet, path, nil, tenantID, tenantCode, out)
|
||||
}
|
||||
|
||||
func (c *Client) Post(ctx context.Context, path string, in any, out any) error {
|
||||
return c.PostForTenant(ctx, path, in, 0, "", out)
|
||||
}
|
||||
|
||||
func (c *Client) PostForTenant(ctx context.Context, path string, in any, tenantID int64, tenantCode string, out any) error {
|
||||
body, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "序列化工作台请求失败")
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodPost, path, body, tenantID, tenantCode, out)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method string, path string, body []byte, tenantID int64, tenantCode string, out any) error {
|
||||
req, err := c.newRequest(ctx, method, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantID > 0 {
|
||||
req.Header.Set("X-Tenant-ID", strconv.FormatInt(tenantID, 10))
|
||||
}
|
||||
if tenantCode = strings.TrimSpace(tenantCode); tenantCode != "" {
|
||||
req.Header.Set("X-Tenant-Code", tenantCode)
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package imworkbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPostForTenantAddsTenantHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Tenant-ID"); got != "23" {
|
||||
t.Fatalf("X-Tenant-ID = %q, want 23", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Tenant-Code"); got != "merchant-23" {
|
||||
t.Fatalf("X-Tenant-Code = %q, want merchant-23", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"tenant_id":23}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
conf: Config{BaseURL: server.URL, ServiceName: "test", Secret: "secret", Timeout: time.Second},
|
||||
http: server.Client(),
|
||||
}
|
||||
var response struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
}
|
||||
if err := client.PostForTenant(context.Background(), "/initialize", map[string]any{"tenant_id": 23}, 23, "merchant-23", &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.TenantID != 23 {
|
||||
t.Fatalf("tenant_id = %d, want 23", response.TenantID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package maoyuadmin 是 im_manager 调用毛隅手记服务(maoyu_server)内部管理接口的客户端。
|
||||
//
|
||||
// 与 cultivateadmin 相互独立:路径前缀固定为 /internal/admin/maoyu/tenants/{tenant_code},
|
||||
// 配置段为 maoyuAdminApi,密钥优先使用 maoyuAdminApi.serviceSecret,
|
||||
// 未配置时回落到与 imInternalApi 共享的密钥。
|
||||
package maoyuadmin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "http://maoyu-server.maoyu-system.svc.cluster.local:8080"
|
||||
maxBodySize = 10 << 20
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
ServiceName string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Scope struct {
|
||||
TenantID int64
|
||||
TenantCode string
|
||||
OperatorID int64
|
||||
RoleKey string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
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:"perPage"`
|
||||
}
|
||||
|
||||
func DefaultClient(ctx context.Context) *Client {
|
||||
timeout := g.Cfg().MustGet(ctx, "maoyuAdminApi.timeout", "5s").Duration()
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
baseURL := resolveValue(g.Cfg().MustGet(ctx, "maoyuAdminApi.baseUrl", defaultBaseURL).String())
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
secret := firstValue(
|
||||
g.Cfg().MustGet(ctx, "maoyuAdminApi.serviceSecret").String(),
|
||||
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"),
|
||||
)
|
||||
return NewClient(Config{
|
||||
BaseURL: baseURL,
|
||||
ServiceName: firstValue(g.Cfg().MustGet(ctx, "maoyuAdminApi.serviceName", "im-manager").String(), "im-manager"),
|
||||
Secret: secret,
|
||||
Timeout: timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func NewClient(conf Config) *Client {
|
||||
return &Client{conf: conf, http: &http.Client{Timeout: conf.Timeout}}
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, scope Scope, path string, values url.Values, out any) error {
|
||||
if values != nil {
|
||||
if query := values.Encode(); query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
}
|
||||
return c.doJSON(ctx, http.MethodGet, scope, path, nil, out)
|
||||
}
|
||||
|
||||
func (c *Client) Post(ctx context.Context, scope Scope, path string, in any, out any) error {
|
||||
return c.write(ctx, http.MethodPost, scope, path, in, out)
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, scope Scope, path string, in any, out any) error {
|
||||
return c.write(ctx, http.MethodPut, scope, path, in, out)
|
||||
}
|
||||
|
||||
func (c *Client) write(ctx context.Context, method string, scope Scope, path string, in any, out any) error {
|
||||
body, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "序列化毛隅手记服务请求失败")
|
||||
}
|
||||
return c.doJSON(ctx, method, scope, path, body, out)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method string, scope Scope, path string, body []byte, out any) error {
|
||||
req, err := c.newRequest(ctx, method, scope, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "Maoyu admin request failed url:%s err:%+v", req.URL.String(), err)
|
||||
return gerror.New("毛隅手记服务暂不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取毛隅手记服务响应失败")
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &envelope); err != nil {
|
||||
g.Log().Warningf(ctx, "Maoyu admin invalid response status:%d requestId:%s", resp.StatusCode, scope.RequestID)
|
||||
return gerror.New("毛隅手记服务返回异常")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || envelope.Code != 0 {
|
||||
return maoyuError(ctx, resp.StatusCode, envelope.Reason, envelope.Message, envelope.RequestID)
|
||||
}
|
||||
if out == nil || len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
if err = unmarshalSnakeCase(envelope.Data, out); err != nil {
|
||||
return gerror.Wrap(err, "解析毛隅手记服务响应失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method string, scope Scope, path string, body []byte) (*http.Request, error) {
|
||||
baseURL := strings.TrimRight(resolveValue(c.conf.BaseURL), "/")
|
||||
secret := resolveValue(c.conf.Secret)
|
||||
if baseURL == "" {
|
||||
return nil, gerror.New("毛隅手记服务地址未配置")
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, gerror.New("毛隅手记服务内部鉴权密钥未配置")
|
||||
}
|
||||
if scope.TenantID <= 0 || strings.TrimSpace(scope.TenantCode) == "" || scope.OperatorID <= 0 {
|
||||
return nil, gerror.New("毛隅手记服务管理范围不正确")
|
||||
}
|
||||
requestID := strings.TrimSpace(scope.RequestID)
|
||||
if requestID == "" {
|
||||
requestID = strings.TrimSpace(gctx.CtxId(ctx))
|
||||
}
|
||||
nonce, err := randomNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = "manager-" + nonce
|
||||
}
|
||||
prefix := "/internal/admin/maoyu/tenants/" + url.PathEscape(strings.TrimSpace(scope.TenantCode))
|
||||
req, err := http.NewRequestWithContext(ctx, method, baseURL+prefix+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "创建毛隅手记服务请求失败")
|
||||
}
|
||||
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||
tenantID := strconv.FormatInt(scope.TenantID, 10)
|
||||
operatorID := strconv.FormatInt(scope.OperatorID, 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{
|
||||
method,
|
||||
req.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestamp,
|
||||
nonce,
|
||||
tenantID,
|
||||
operatorID,
|
||||
scope.RoleKey,
|
||||
requestID,
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Name", firstValue(c.conf.ServiceName, "im-manager"))
|
||||
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-Tenant-ID", tenantID)
|
||||
req.Header.Set("X-Operator-ID", operatorID)
|
||||
req.Header.Set("X-Role-Key", scope.RoleKey)
|
||||
req.Header.Set("X-Request-ID", requestID)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func maoyuError(ctx context.Context, status int, reason, message, requestID string) error {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
g.Log().Warningf(ctx, "Maoyu admin auth failed status:%d reason:%s requestId:%s", status, reason, requestID)
|
||||
return gerror.New("毛隅手记服务内部鉴权失败")
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
g.Log().Warningf(ctx, "Maoyu admin failed status:%d reason:%s requestId:%s", status, reason, requestID)
|
||||
if status == http.StatusServiceUnavailable && message != "" {
|
||||
return gerror.New(message)
|
||||
}
|
||||
return gerror.New("毛隅手记服务暂不可用")
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
return gerror.New(message)
|
||||
}
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
return gerror.New(reason)
|
||||
}
|
||||
return gerror.New("毛隅手记服务请求失败")
|
||||
}
|
||||
|
||||
func unmarshalSnakeCase(raw []byte, out any) error {
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
converted, err := json.Marshal(convertKeys(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(converted, out)
|
||||
}
|
||||
|
||||
func convertKeys(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
result[snakeToLowerCamel(key)] = convertKeys(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = convertKeys(item)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func snakeToLowerCamel(value string) string {
|
||||
parts := strings.Split(value, "_")
|
||||
if len(parts) == 1 {
|
||||
return value
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(parts[0])
|
||||
for _, part := range parts[1:] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(part)
|
||||
builder.WriteRune(unicode.ToUpper(runes[0]))
|
||||
builder.WriteString(string(runes[1:]))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomNonce() (string, error) {
|
||||
var value [18]byte
|
||||
if _, err := cryptorand.Read(value[:]); err != nil {
|
||||
return "", gerror.Wrap(err, "生成毛隅手记服务请求随机数失败")
|
||||
}
|
||||
return hex.EncodeToString(value[:]), nil
|
||||
}
|
||||
|
||||
func firstValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
if resolved := resolveValue(value); resolved != "" {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
|
||||
return strings.TrimSpace(os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")))
|
||||
}
|
||||
if strings.HasPrefix(value, "$") && !strings.Contains(value, "://") {
|
||||
return strings.TrimSpace(os.Getenv(strings.TrimPrefix(value, "$")))
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package maoyuadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientSignsScopeAndTargetsMaoyuPrefix(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.URL.RequestURI() != "/internal/admin/maoyu/tenants/default/content-reviews?status=pending" {
|
||||
t.Fatalf("request URI = %q", r.URL.RequestURI())
|
||||
}
|
||||
if r.Header.Get("X-Tenant-ID") != "1" || r.Header.Get("X-Operator-ID") != "14" {
|
||||
t.Fatalf("unexpected scope headers")
|
||||
}
|
||||
hash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
hex.EncodeToString(hash[:]),
|
||||
r.Header.Get("X-Timestamp"),
|
||||
r.Header.Get("X-Nonce"),
|
||||
"1",
|
||||
"14",
|
||||
"imManager",
|
||||
"req-test",
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
t.Fatal("signature mismatch")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{"post_count": 7, "pending_audit_count": 3},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: secret, Timeout: time.Second})
|
||||
client.http = server.Client()
|
||||
var out struct {
|
||||
PostCount int64 `json:"postCount"`
|
||||
PendingAuditCount int64 `json:"pendingAuditCount"`
|
||||
}
|
||||
err := client.Get(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RoleKey: "imManager", RequestID: "req-test"}, "/content-reviews", url.Values{"status": []string{"pending"}}, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.PostCount != 7 || out.PendingAuditCount != 3 {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsBusinessMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"code":40901,"reason":"STATE_CONFLICT","message":"当前状态不允许操作"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: "secret", Timeout: time.Second})
|
||||
client.http = server.Client()
|
||||
err := client.Post(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RequestID: "req-test"}, "/content-reviews/feed_post/1/actions", map[string]any{"action": "approve"}, nil)
|
||||
if err == nil || err.Error() != "当前状态不允许操作" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -468,11 +468,12 @@ 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 {
|
||||
// provisioning(初始化中)是新建商户创建管理员前的合法过渡态,必须放行,否则新建商户必失败
|
||||
count, queryErr := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImTenant.Columns().Id, in.TenantId).
|
||||
Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive).
|
||||
WhereIn(dao.ImTenant.Columns().State, g.Slice{consts.ImTenantStateActive, consts.ImTenantStateProvisioning}).
|
||||
Count()
|
||||
if queryErr != nil {
|
||||
return gerror.Wrap(queryErr, "校验IM租户失败")
|
||||
@@ -508,6 +509,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 +610,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 +960,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 +974,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)
|
||||
|
||||
@@ -53,8 +53,10 @@ func (s *sAdminMenu) Delete(ctx context.Context, in *adminin.MenuDeleteInp) (err
|
||||
if !exist.IsEmpty() {
|
||||
return gerror.New("请先删除该菜单下的所有菜单!")
|
||||
}
|
||||
_, err = dao.AdminMenu.Ctx(ctx).Where(dao.AdminMenu.Columns().Id, in.Id).Delete()
|
||||
return
|
||||
if _, err = dao.AdminMenu.Ctx(ctx).Where(dao.AdminMenu.Columns().Id, in.Id).Delete(); err != nil {
|
||||
return
|
||||
}
|
||||
return casbin.Refresh(ctx)
|
||||
}
|
||||
|
||||
// VerifyUnique 验证菜单唯一属性
|
||||
@@ -104,7 +106,7 @@ func (s *sAdminMenu) Edit(ctx context.Context, in *adminin.MenuEditInp) (err err
|
||||
return
|
||||
}
|
||||
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
in.Pid, in.Level, in.Tree, err = hgorm.AutoUpdateTree(ctx, &dao.AdminMenu, in.Id, in.Pid)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -121,8 +123,11 @@ func (s *sAdminMenu) Edit(ctx context.Context, in *adminin.MenuEditInp) (err err
|
||||
return err
|
||||
}
|
||||
}
|
||||
return casbin.Refresh(ctx)
|
||||
})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
return casbin.Refresh(ctx)
|
||||
}
|
||||
|
||||
// List 获取菜单列表
|
||||
|
||||
@@ -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 (
|
||||
@@ -53,9 +82,9 @@ func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
ok, err := casbin.Enforcer.Enforce(user.RoleKey, path, method)
|
||||
ok, err := casbin.Verify(ctx, user.RoleKey, path, method)
|
||||
if err != nil {
|
||||
g.Log().Infof(ctx, "admin Verify Enforce err:%+v", err)
|
||||
g.Log().Infof(ctx, "admin Verify cache err:%+v", err)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
@@ -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)+"%")
|
||||
}
|
||||
@@ -186,7 +221,10 @@ func (s *sAdminRole) Edit(ctx context.Context, in *adminin.RoleEditInp) (err err
|
||||
// 如果当前角色有子级,更新子级tree关系树
|
||||
return updateRoleChildrenTree(ctx, in.Id, in.Level, in.Tree)
|
||||
})
|
||||
return
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return casbin.Refresh(ctx)
|
||||
}
|
||||
|
||||
// 新增
|
||||
@@ -194,7 +232,7 @@ func (s *sAdminRole) Edit(ctx context.Context, in *adminin.RoleEditInp) (err err
|
||||
err = gerror.Wrap(err, consts.ErrorORM)
|
||||
return
|
||||
}
|
||||
return
|
||||
return casbin.Refresh(ctx)
|
||||
}
|
||||
|
||||
func updateRoleChildrenTree(ctx context.Context, _id int64, _level int, _tree string) (err error) {
|
||||
@@ -253,8 +291,9 @@ func (s *sAdminRole) Delete(ctx context.Context, in *adminin.RoleDeleteInp) (err
|
||||
|
||||
if _, err = dao.AdminRole.Ctx(ctx).Where("id", in.Id).Delete(); err != nil {
|
||||
err = gerror.Wrap(err, consts.ErrorORM)
|
||||
return
|
||||
}
|
||||
return
|
||||
return casbin.Refresh(ctx)
|
||||
}
|
||||
|
||||
func (s *sAdminRole) DataScopeSelect() (res form.Selects) {
|
||||
@@ -326,7 +365,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
|
||||
|
||||
@@ -40,6 +40,8 @@ func (s *sHook) setAdminStaticCacheHeader(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "/admin/assets/") {
|
||||
r.Response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
r.Response.Header().Set("Cache-Control", "no-cache, must-revalidate")
|
||||
r.Response.Header().Set("Pragma", "no-cache")
|
||||
r.Response.Header().Set("Expires", "0")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ func (s *sMiddleware) AdminAuth(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 已绑定 IM 租户的后台账号需要读取自己的租户选项,用于各 IM 页面初始化租户下拉。
|
||||
// 业务层会继续按登录态 tenant_id 收敛查询范围,这里只绕过菜单权限,不扩大数据范围。
|
||||
if path == "/imTenant/list" && r.Method == "GET" && contexts.GetTenantId(ctx) > 0 {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证路由访问权限
|
||||
if !service.AdminRole().Verify(ctx, path, r.Method) {
|
||||
g.Log().Debugf(ctx, "AdminAuth fail path:%+v, GetRoleKey:%+v, r.Method:%+v", path, contexts.GetRoleKey(ctx), r.Method)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"hotgo/internal/library/cultivateadmin"
|
||||
"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, in *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateDashboardModel)
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/dashboard", nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, 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
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "object_type", in.ObjectType)
|
||||
cultivateSetQuery(values, "author_id", in.AuthorId)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "keyword", in.Keyword)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page cultivateadmin.Page[*sysin.CultivateContentListModel]
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/content-reviews", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateContentListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) View(ctx context.Context, in *sysin.CultivateContentViewInp) (*sysin.CultivateContentViewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateContentViewModel)
|
||||
path := "/content-reviews/" + url.PathEscape(in.ObjectType) + "/" + url.PathEscape(in.Id)
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, path, nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/content-reviews/" + url.PathEscape(in.ObjectType) + "/" + url.PathEscape(in.Id) + "/actions"
|
||||
return cultivateadmin.DefaultClient(ctx).Post(ctx, scope, path, map[string]any{"action": in.Action, "reason": strings.TrimSpace(in.Reason)}, nil)
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) BatchReview(ctx context.Context, in *sysin.CultivateContentBatchReviewInp) (*sysin.CultivateContentBatchReviewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateContentBatchReviewModel)
|
||||
err = cultivateadmin.DefaultClient(ctx).Post(ctx, scope, "/content-reviews/batch-actions", map[string]any{
|
||||
"object_type": in.ObjectType, "ids": in.Ids, "action": in.Action, "reason": strings.TrimSpace(in.Reason),
|
||||
}, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "object_type", in.ObjectType)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "reporter_id", in.ReporterId)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page cultivateadmin.Page[*sysin.CultivateReportListModel]
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/reports", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateReportListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) ReportView(ctx context.Context, in *sysin.CultivateReportViewInp) (*sysin.CultivateReportViewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateReportViewModel)
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/reports/"+url.PathEscape(in.Id), nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cultivateadmin.DefaultClient(ctx).Post(ctx, scope, "/reports/"+url.PathEscape(in.Id)+"/actions", map[string]any{
|
||||
"action": in.Action, "handle_result": in.HandleResult, "content_action": in.ContentAction,
|
||||
"user_action": in.UserAction, "reason": in.Reason,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) LogList(ctx context.Context, in *sysin.CultivateOperationLogListInp) (list []*sysin.CultivateOperationLogListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "operator_id", in.OperatorId)
|
||||
cultivateSetQuery(values, "operation_type", in.OperationType)
|
||||
cultivateSetQuery(values, "target_type", in.TargetType)
|
||||
cultivateSetQuery(values, "target_id", in.TargetId)
|
||||
cultivateSetQuery(values, "request_id", in.RequestId)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page cultivateadmin.Page[*sysin.CultivateOperationLogListModel]
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/operation-logs", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateOperationLogListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/cultivateadmin"
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
type sSysCultivateFlower struct{}
|
||||
|
||||
func NewSysCultivateFlower() *sSysCultivateFlower { return &sSysCultivateFlower{} }
|
||||
func init() { service.RegisterSysCultivateFlower(NewSysCultivateFlower()) }
|
||||
|
||||
type cultivateFlowerDTO struct {
|
||||
ID string `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"`
|
||||
ShareConfig any `json:"shareConfig"`
|
||||
SortNo int `json:"sortNo"`
|
||||
StageCount int `json:"stageCount"`
|
||||
StageRules []*cultivateStageRuleDTO `json:"stageRules"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type cultivateStageRuleDTO struct {
|
||||
ID string `json:"id"`
|
||||
Stage string `json:"stage"`
|
||||
NextStage string `json:"nextStage"`
|
||||
RequiredPoints uint `json:"requiredPoints"`
|
||||
WaterPoints uint `json:"waterPoints"`
|
||||
FertilizePoints uint `json:"fertilizePoints"`
|
||||
WaitHours uint `json:"waitHours"`
|
||||
AllowedActions []string `json:"allowedActions"`
|
||||
ImageFileID string `json:"imageFileId"`
|
||||
AnimationFileID string `json:"animationFileId"`
|
||||
ProgressTextTemplate string `json:"progressTextTemplate"`
|
||||
CompletionText string `json:"completionText"`
|
||||
SortNo int `json:"sortNo"`
|
||||
}
|
||||
|
||||
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 cultivateAdminScope(ctx context.Context) (cultivateadmin.Scope, error) {
|
||||
tenant, err := resolveImAdminTenant(ctx, cultivateTenantID(ctx))
|
||||
if err != nil {
|
||||
return cultivateadmin.Scope{}, err
|
||||
}
|
||||
operatorID := contexts.GetUserId(ctx)
|
||||
if operatorID <= 0 {
|
||||
return cultivateadmin.Scope{}, gerror.New("当前管理员身份无效")
|
||||
}
|
||||
return cultivateadmin.Scope{
|
||||
TenantID: tenant.Id, TenantCode: tenant.Code, OperatorID: operatorID,
|
||||
RoleKey: contexts.GetRoleKey(ctx), RequestID: gctx.CtxId(ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateFlower) List(ctx context.Context, in *sysin.CultivateFlowerListInp) (list []*sysin.CultivateFlowerListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "flower_code", in.FlowerCode)
|
||||
cultivateSetQuery(values, "flower_name", in.FlowerName)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "config_version", in.ConfigVersion)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page cultivateadmin.Page[cultivateFlowerDTO]
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/flowers", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
list = make([]*sysin.CultivateFlowerListModel, 0, len(page.Items))
|
||||
for index := range page.Items {
|
||||
item, convertErr := flowerListModel(&page.Items[index])
|
||||
if convertErr != nil {
|
||||
return nil, 0, convertErr
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateFlower) View(ctx context.Context, in *sysin.CultivateFlowerViewInp) (*sysin.CultivateFlowerViewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var item cultivateFlowerDTO
|
||||
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/flowers/"+strconv.FormatUint(in.Id, 10), nil, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return flowerViewModel(&item)
|
||||
}
|
||||
|
||||
func (s *sSysCultivateFlower) Edit(ctx context.Context, in *sysin.CultivateFlowerEditInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := flowerPayload(in)
|
||||
var response struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if in.Id == 0 {
|
||||
err = cultivateadmin.DefaultClient(ctx).Post(ctx, scope, "/flowers", payload, &response)
|
||||
} else {
|
||||
err = cultivateadmin.DefaultClient(ctx).Put(ctx, scope, "/flowers/"+strconv.FormatUint(in.Id, 10), payload, &response)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Id == 0 && response.ID != "" {
|
||||
in.Id, err = strconv.ParseUint(response.ID, 10, 64)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "解析养成服务花种ID失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateFlower) Status(ctx context.Context, in *sysin.CultivateFlowerStatusInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := cultivateAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cultivateadmin.DefaultClient(ctx).Post(ctx, scope, "/flowers/"+strconv.FormatUint(in.Id, 10)+"/status", map[string]any{
|
||||
"status": in.Status, "reason": strings.TrimSpace(in.Reason),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func flowerPayload(in *sysin.CultivateFlowerEditInp) map[string]any {
|
||||
shareConfig := map[string]any{}
|
||||
if in.ShareConfig != nil {
|
||||
_ = json.Unmarshal([]byte(in.ShareConfig.MustToJsonString()), &shareConfig)
|
||||
}
|
||||
rules := make([]map[string]any, 0, len(in.StageRules))
|
||||
for _, rule := range in.StageRules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, map[string]any{
|
||||
"id": strconv.FormatUint(rule.Id, 10), "stage": rule.Stage, "next_stage": rule.NextStage,
|
||||
"required_points": rule.RequiredPoints, "water_points": rule.WaterPoints, "fertilize_points": rule.FertilizePoints,
|
||||
"wait_hours": rule.WaitHours, "allowed_actions": rule.AllowedActions, "image_file_id": rule.ImageFileId, "animation_file_id": rule.AnimationFileId,
|
||||
"progress_text_template": rule.ProgressTextTemplate, "completion_text": rule.CompletionText, "sort_no": rule.SortNo,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"flower_code": in.FlowerCode, "flower_name": in.FlowerName, "flower_desc": in.FlowerDesc,
|
||||
"icon_file_id": in.IconFileId, "status": in.Status, "daily_water_limit": in.DailyWaterLimit,
|
||||
"daily_fertilize_limit": in.DailyFertilizeLimit, "share_config": shareConfig, "sort_no": in.SortNo, "stage_rules": rules,
|
||||
}
|
||||
}
|
||||
|
||||
func flowerListModel(item *cultivateFlowerDTO) (*sysin.CultivateFlowerListModel, error) {
|
||||
id, err := parseCultivateUint(item.ID, "花种ID")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sysin.CultivateFlowerListModel{
|
||||
Id: id, FlowerCode: item.FlowerCode, FlowerName: item.FlowerName, FlowerDesc: item.FlowerDesc,
|
||||
IconFileId: item.IconFileID, Status: item.Status, ConfigVersion: item.ConfigVersion,
|
||||
DailyWaterLimit: item.DailyWaterLimit, DailyFertilizeLimit: item.DailyFertilizeLimit,
|
||||
SortNo: item.SortNo, StageCount: item.StageCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func flowerViewModel(item *cultivateFlowerDTO) (*sysin.CultivateFlowerViewModel, error) {
|
||||
list, err := flowerListModel(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shareConfig, _ := json.Marshal(item.ShareConfig)
|
||||
result := &sysin.CultivateFlowerViewModel{
|
||||
CultivateFlowerEditInp: sysin.CultivateFlowerEditInp{
|
||||
Id: list.Id, FlowerCode: list.FlowerCode, FlowerName: list.FlowerName, FlowerDesc: list.FlowerDesc,
|
||||
IconFileId: list.IconFileId, Status: list.Status, DailyWaterLimit: list.DailyWaterLimit,
|
||||
DailyFertilizeLimit: list.DailyFertilizeLimit, ShareConfig: gjson.New(string(shareConfig)), SortNo: list.SortNo,
|
||||
},
|
||||
ConfigVersion: list.ConfigVersion, CreatedAt: list.CreatedAt, UpdatedAt: list.UpdatedAt,
|
||||
}
|
||||
for _, rule := range item.StageRules {
|
||||
id, parseErr := parseCultivateUint(rule.ID, "成长阶段ID")
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
result.StageRules = append(result.StageRules, &sysin.CultivateStageRuleEditInp{
|
||||
Id: id, Stage: rule.Stage, NextStage: rule.NextStage, RequiredPoints: rule.RequiredPoints,
|
||||
WaterPoints: rule.WaterPoints, FertilizePoints: rule.FertilizePoints, WaitHours: rule.WaitHours, AllowedActions: rule.AllowedActions,
|
||||
ImageFileId: rule.ImageFileID, AnimationFileId: rule.AnimationFileID,
|
||||
ProgressTextTemplate: rule.ProgressTextTemplate, CompletionText: rule.CompletionText, SortNo: rule.SortNo,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseCultivateUint(value, field string) (uint64, error) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, gerror.Newf("养成服务返回的%s不正确", field)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func normalizeCultivatePage(page *form.PageReq) {
|
||||
if page.Page <= 0 {
|
||||
page.Page = 1
|
||||
}
|
||||
if page.PerPage <= 0 {
|
||||
page.PerPage = 10
|
||||
}
|
||||
if page.PerPage > 100 {
|
||||
page.PerPage = 100
|
||||
}
|
||||
}
|
||||
|
||||
func cultivatePageValues(page, perPage int) url.Values {
|
||||
return url.Values{"page": []string{strconv.Itoa(page)}, "per_page": []string{strconv.Itoa(perPage)}}
|
||||
}
|
||||
|
||||
func cultivateSetQuery(values url.Values, key, value string) {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
values.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func setTimeRange(values url.Values, value []*gtime.Time) {
|
||||
if len(value) != 2 {
|
||||
return
|
||||
}
|
||||
if value[0] != nil {
|
||||
values.Set("created_from", value[0].Format(time.RFC3339))
|
||||
}
|
||||
if value[1] != nil {
|
||||
values.Set("created_to", value[1].Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,13 +36,16 @@ func (s *sSysImAppRelease) List(ctx context.Context, in *sysin.ImAppReleaseListI
|
||||
return
|
||||
}
|
||||
mod := s.model(ctx, db)
|
||||
if in.AppCode != "" {
|
||||
mod = mod.Where("app_code", in.AppCode)
|
||||
}
|
||||
if in.Platform != "" {
|
||||
mod = mod.Where("platform", in.Platform)
|
||||
}
|
||||
if in.Channel != "" {
|
||||
mod = mod.WhereLike("channel", "%"+in.Channel+"%")
|
||||
}
|
||||
mod = mod.Page(in.Page, in.PerPage).OrderAsc("platform").OrderAsc("channel")
|
||||
mod = mod.Page(in.Page, in.PerPage).OrderAsc("app_code").OrderAsc("platform").OrderAsc("channel")
|
||||
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
||||
err = gerror.Wrap(err, "获取IM客户端版本策略失败,请稍后重试!")
|
||||
return
|
||||
@@ -59,6 +62,7 @@ func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditI
|
||||
return
|
||||
}
|
||||
data := g.Map{
|
||||
"app_code": in.AppCode,
|
||||
"platform": in.Platform,
|
||||
"channel": in.Channel,
|
||||
"latest_version": in.LatestVersion,
|
||||
@@ -85,18 +89,18 @@ func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditI
|
||||
return gerror.Wrap(err, "新增IM客户端版本策略失败,请稍后重试!")
|
||||
}
|
||||
idValue, err := db.Model(imAppReleasesTable).Safe().Ctx(ctx).Fields("id").
|
||||
Where("platform", in.Platform).Where("channel", in.Channel).Value()
|
||||
Where("app_code", in.AppCode).Where("platform", in.Platform).Where("channel", in.Channel).Value()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取IM客户端版本策略ID失败,请稍后重试!")
|
||||
}
|
||||
targetId = idValue.Int64()
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, 0, action, "im_app_release", targetId, "success", g.Map{
|
||||
"platform": in.Platform, "channel": in.Channel, "update_type": in.UpdateType,
|
||||
"app_code": in.AppCode, "platform": in.Platform, "channel": in.Channel, "update_type": in.UpdateType,
|
||||
})
|
||||
if err = imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{
|
||||
TenantCode: "*", EventType: "app.release.updated", Group: "app_release",
|
||||
Keys: []string{in.Platform, in.Channel}, BizID: in.Platform + ":" + in.Channel,
|
||||
Keys: []string{in.AppCode, in.Platform, in.Channel}, BizID: in.AppCode + ":" + in.Platform + ":" + in.Channel,
|
||||
Actor: &imkafka.Actor{Type: "admin", ID: fmt.Sprint(contexts.GetUserId(ctx))},
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
}); err != nil {
|
||||
@@ -108,6 +112,7 @@ func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditI
|
||||
func (s *sSysImAppRelease) model(ctx context.Context, db gdb.DB) *gdb.Model {
|
||||
return db.Model(imAppReleasesTable).Safe().Ctx(ctx).Fields(
|
||||
"id",
|
||||
"app_code",
|
||||
"platform",
|
||||
"channel",
|
||||
"latest_version",
|
||||
@@ -125,28 +130,42 @@ func (s *sSysImAppRelease) model(ctx context.Context, db gdb.DB) *gdb.Model {
|
||||
}
|
||||
|
||||
func (s *sSysImAppRelease) ensureDefaults(ctx context.Context, db gdb.DB) (err error) {
|
||||
apps := []string{consts.ImAppCodeIm, consts.ImAppCodeGarden}
|
||||
defaults := []g.Map{
|
||||
{"platform": consts.ImAppPlatformIos, "channel": "stable", "latest_version": "1.0.0", "min_supported_version": "1.0.0", "update_type": consts.ImAppUpdateTypeNone, "release_notes": "当前已是最新版本。", "remind_after_seconds": 0},
|
||||
{"platform": consts.ImAppPlatformAndroid, "channel": "stable", "latest_version": "1.0.0", "min_supported_version": "1.0.0", "update_type": consts.ImAppUpdateTypeNone, "release_notes": "当前已是最新版本。", "remind_after_seconds": 0},
|
||||
}
|
||||
now := gtime.Now()
|
||||
for _, item := range defaults {
|
||||
var count int
|
||||
if count, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).
|
||||
Where("platform", item["platform"]).Where("channel", item["channel"]).Count(); err != nil {
|
||||
return gerror.Wrap(err, "检查IM客户端默认版本策略失败,请稍后重试!")
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
item["store_url"] = ""
|
||||
item["package_url"] = ""
|
||||
item["checksum"] = ""
|
||||
item["published_at"] = now
|
||||
item["created_at"] = now
|
||||
item["updated_at"] = now
|
||||
if _, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).Data(item).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "初始化IM客户端默认版本策略失败,请稍后重试!")
|
||||
for _, appCode := range apps {
|
||||
for _, item := range defaults {
|
||||
var count int
|
||||
if count, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).
|
||||
Where("app_code", appCode).
|
||||
Where("platform", item["platform"]).Where("channel", item["channel"]).Count(); err != nil {
|
||||
return gerror.Wrap(err, "检查IM客户端默认版本策略失败,请稍后重试!")
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
row := g.Map{
|
||||
"app_code": appCode,
|
||||
"platform": item["platform"],
|
||||
"channel": item["channel"],
|
||||
"latest_version": item["latest_version"],
|
||||
"min_supported_version": item["min_supported_version"],
|
||||
"update_type": item["update_type"],
|
||||
"release_notes": item["release_notes"],
|
||||
"remind_after_seconds": item["remind_after_seconds"],
|
||||
"store_url": "",
|
||||
"package_url": "",
|
||||
"checksum": "",
|
||||
"published_at": now,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if _, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).Data(row).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "初始化IM客户端默认版本策略失败,请稍后重试!")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -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).GetForTenant(ctx, "/internal/admin/workbench/feedback-config", nil, tenant.Id, tenant.Code, 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).PostForTenant(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意见反馈配置失败,请稍后重试!")
|
||||
}, tenant.Id, tenant.Code, 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
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list
|
||||
return
|
||||
}
|
||||
|
||||
mod := s.groupModel(ctx, db)
|
||||
mod := s.groupModel(ctx, db, tenant.Id)
|
||||
mod = s.applyGroupWhere(mod, in)
|
||||
mod = mod.Page(in.Page, in.PerPage).OrderDesc("t.touchedat").OrderDesc("t.id")
|
||||
|
||||
@@ -67,11 +67,11 @@ func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model {
|
||||
return db.Model("im_topics t").Safe().Ctx(ctx).
|
||||
LeftJoin("im_group_settings gs", "gs.topic = t.name").
|
||||
LeftJoin("im_users owner", "owner.id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
LeftJoin("im_user_profiles owner_profile", "owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB, tenantId int64) *gdb.Model {
|
||||
return db.Model(tenantTablePrefix(tenantId)+"topics t").Safe().Ctx(ctx).
|
||||
LeftJoin("im_group_settings gs", "gs.tenant_id = t.tenant_id AND gs.topic = t.name").
|
||||
LeftJoin("im_users owner", "owner.tenant_id = t.tenant_id AND owner.id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
LeftJoin("im_user_profiles owner_profile", "owner_profile.tenant_id = t.tenant_id AND owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
Fields(
|
||||
"t.name topic",
|
||||
"t.state",
|
||||
@@ -103,6 +103,7 @@ func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model {
|
||||
"COALESCE(gs.version, 0) settings_version",
|
||||
"gs.updated_at settings_updated_at",
|
||||
).
|
||||
Where("t.tenant_id", tenantId).
|
||||
WhereLike("t.name", "grp%")
|
||||
}
|
||||
|
||||
@@ -139,10 +140,10 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *
|
||||
return
|
||||
}
|
||||
|
||||
err = db.Model("im_topics t").Safe().Ctx(ctx).
|
||||
LeftJoin("im_group_settings gs", "gs.topic = t.name").
|
||||
LeftJoin("im_users owner", "owner.id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
LeftJoin("im_user_profiles owner_profile", "owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
err = db.Model(tenantTablePrefix(tenant.Id)+"topics t").Safe().Ctx(ctx).
|
||||
LeftJoin("im_group_settings gs", "gs.tenant_id = t.tenant_id AND gs.topic = t.name").
|
||||
LeftJoin("im_users owner", "owner.tenant_id = t.tenant_id AND owner.id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
LeftJoin("im_user_profiles owner_profile", "owner_profile.tenant_id = t.tenant_id AND owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)").
|
||||
Fields(
|
||||
"t.name topic",
|
||||
"t.state",
|
||||
@@ -176,6 +177,7 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *
|
||||
"COALESCE(gs.version, 0) settings_version",
|
||||
"gs.updated_at settings_updated_at",
|
||||
).
|
||||
Where("t.tenant_id", tenant.Id).
|
||||
Where("t.name", in.Topic).
|
||||
WhereLike("t.name", "grp%").
|
||||
Scan(&res)
|
||||
@@ -196,27 +198,27 @@ func (s *sSysImGroup) MemberList(ctx context.Context, in *sysin.ImGroupMemberLis
|
||||
return
|
||||
}
|
||||
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.hasGroupMemberTruth(ctx, db, in.Topic) {
|
||||
return s.memberListFromBusiness(ctx, db, in)
|
||||
if s.hasGroupMemberTruth(ctx, db, tenant.Id, in.Topic) {
|
||||
return s.memberListFromBusiness(ctx, db, tenant.Id, in)
|
||||
}
|
||||
return s.memberListFromProjection(ctx, db, in)
|
||||
return s.memberListFromProjection(ctx, db, tenant.Id, in)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) hasGroupMemberTruth(ctx context.Context, db gdb.DB, topic string) bool {
|
||||
count, err := db.Model("im_group_members").Safe().Ctx(ctx).Where("topic", topic).Count()
|
||||
func (s *sSysImGroup) hasGroupMemberTruth(ctx context.Context, db gdb.DB, tenantId int64, topic string) bool {
|
||||
count, err := db.Model(tenantTablePrefix(tenantId)+"group_members").Safe().Ctx(ctx).Where("tenant_id", tenantId).Where("topic", topic).Count()
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
|
||||
mod := db.Model("im_group_members gm").Safe().Ctx(ctx).
|
||||
LeftJoin("im_users u", "u.id = gm.user_id").
|
||||
LeftJoin("im_user_profiles up", "up.user_id = gm.user_id").
|
||||
LeftJoin("im_subscriptions s", "s.topic = gm.topic AND s.userid = gm.user_id").
|
||||
func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, tenantId int64, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
|
||||
mod := db.Model(tenantTablePrefix(tenantId)+"group_members gm").Safe().Ctx(ctx).
|
||||
LeftJoin("im_users u", "u.tenant_id = gm.tenant_id AND u.id = gm.user_id").
|
||||
LeftJoin("im_user_profiles up", "up.tenant_id = gm.tenant_id AND up.user_id = gm.user_id").
|
||||
LeftJoin(tenantTablePrefix(tenantId)+"subscriptions s", "s.tenant_id = gm.tenant_id AND s.topic = gm.topic AND s.userid = gm.user_id").
|
||||
Fields(
|
||||
"gm.user_id",
|
||||
"up.nickname",
|
||||
@@ -242,6 +244,7 @@ func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, in
|
||||
"gm.removed_by",
|
||||
"gm.version",
|
||||
).
|
||||
Where("gm.tenant_id", tenantId).
|
||||
Where("gm.topic", in.Topic)
|
||||
if in.UserId > 0 {
|
||||
mod = mod.Where("gm.user_id", in.UserId)
|
||||
@@ -260,10 +263,10 @@ func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, in
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) memberListFromProjection(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
|
||||
mod := db.Model("im_subscriptions s").Safe().Ctx(ctx).
|
||||
LeftJoin("im_users u", "u.id = s.userid").
|
||||
LeftJoin("im_user_profiles up", "up.user_id = s.userid").
|
||||
func (s *sSysImGroup) memberListFromProjection(ctx context.Context, db gdb.DB, tenantId int64, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
|
||||
mod := db.Model(tenantTablePrefix(tenantId)+"subscriptions s").Safe().Ctx(ctx).
|
||||
LeftJoin("im_users u", "u.tenant_id = s.tenant_id AND u.id = s.userid").
|
||||
LeftJoin("im_user_profiles up", "up.tenant_id = s.tenant_id AND up.user_id = s.userid").
|
||||
Fields(
|
||||
"s.userid user_id",
|
||||
"up.nickname",
|
||||
@@ -289,6 +292,7 @@ func (s *sSysImGroup) memberListFromProjection(ctx context.Context, db gdb.DB, i
|
||||
"0 removed_by",
|
||||
"0 version",
|
||||
).
|
||||
Where("s.tenant_id", tenantId).
|
||||
Where("s.topic", in.Topic)
|
||||
|
||||
if in.UserId > 0 {
|
||||
@@ -335,6 +339,7 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp)
|
||||
"created_at",
|
||||
"updated_at",
|
||||
).
|
||||
Where("tenant_id", tenant.Id).
|
||||
Where("topic", in.Topic).
|
||||
Scan(&res)
|
||||
if err != nil {
|
||||
@@ -466,7 +471,7 @@ func (s *sSysImGroup) JoinRequestReject(ctx context.Context, in *sysin.ImGroupJo
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp, status string) (err error) {
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -492,10 +497,10 @@ func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJ
|
||||
}
|
||||
applicantUserId := before["applicant_user_id"].Int64()
|
||||
if status == consts.ImGroupRequestStatusAccepted {
|
||||
if txErr = s.upsertGroupSubscription(ctx, tx, in.Topic, applicantUserId, imGroupModeMember, now); txErr != nil {
|
||||
if txErr = s.upsertGroupSubscription(ctx, tx, tenant.Id, in.Topic, applicantUserId, imGroupModeMember, now); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.upsertGroupMember(ctx, tx, in.Topic, applicantUserId, consts.ImGroupRoleMember, "approval", actor, now); txErr != nil {
|
||||
if txErr = s.upsertGroupMember(ctx, tx, tenant.Id, in.Topic, applicantUserId, consts.ImGroupRoleMember, "approval", actor, now); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
@@ -509,7 +514,7 @@ func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJ
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("入群申请已处理")
|
||||
}
|
||||
if txErr = s.recountGroupMembers(ctx, tx, in.Topic); txErr != nil {
|
||||
if txErr = s.recountGroupMembers(ctx, tx, tenant.Id, in.Topic); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
after, txErr := s.groupJoinRequestSnapshot(ctx, tx, in.Topic, in.RequestId)
|
||||
@@ -639,8 +644,8 @@ func (s *sSysImGroup) groupJoinRequestSnapshot(ctx context.Context, tx gdb.TX, t
|
||||
One()
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) groupMemberSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) {
|
||||
return tx.Model("im_group_members").Safe().Ctx(ctx).
|
||||
func (s *sSysImGroup) groupMemberSnapshot(ctx context.Context, tx gdb.TX, tenantId int64, topic string, userId int64) (res gdb.Record, err error) {
|
||||
return tx.Model(tenantTablePrefix(tenantId)+"group_members").Safe().Ctx(ctx).
|
||||
Where("topic", topic).
|
||||
Where("user_id", userId).
|
||||
One()
|
||||
@@ -678,8 +683,8 @@ func (s *sSysImGroup) groupMuteSnapshot(ctx context.Context, tx gdb.TX, topic st
|
||||
One()
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64) error {
|
||||
count, err := tx.Model("im_group_members").Safe().Ctx(ctx).
|
||||
func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, tenantId int64, topic string, userId int64) error {
|
||||
count, err := tx.Model(tenantTablePrefix(tenantId)+"group_members").Safe().Ctx(ctx).
|
||||
Where("topic", topic).
|
||||
Where("user_id", userId).
|
||||
Where("member_status", consts.ImGroupMemberStatusActive).
|
||||
@@ -693,8 +698,8 @@ func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, to
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, topic string, userId int64) error {
|
||||
currentRole, err := tx.Model("im_group_members").Safe().Ctx(ctx).
|
||||
func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, tenantId int64, topic string, userId int64) error {
|
||||
currentRole, err := tx.Model(tenantTablePrefix(tenantId)+"group_members").Safe().Ctx(ctx).
|
||||
Where("topic", topic).
|
||||
Where("user_id", userId).
|
||||
Where("member_status", consts.ImGroupMemberStatusActive).
|
||||
@@ -712,7 +717,7 @@ func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, topic str
|
||||
if maxAdminCount.Int() <= 0 {
|
||||
return nil
|
||||
}
|
||||
adminCount, err := tx.Model("im_group_members").Safe().Ctx(ctx).
|
||||
adminCount, err := tx.Model(tenantTablePrefix(tenantId)+"group_members").Safe().Ctx(ctx).
|
||||
Where("topic", topic).
|
||||
Where("role", consts.ImGroupRoleAdmin).
|
||||
Where("member_status", consts.ImGroupMemberStatusActive).
|
||||
@@ -726,33 +731,38 @@ func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, topic str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) upsertGroupSubscription(ctx context.Context, tx gdb.TX, topic string, userId int64, modeGiven string, now *gtime.Time) error {
|
||||
_, err := tx.Exec(`INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modewant,modegiven,private)
|
||||
VALUES(?,?,NULL,?,?,?,?,CAST('{}' AS JSON))
|
||||
func (s *sSysImGroup) upsertGroupSubscription(ctx context.Context, tx gdb.TX, tenantId int64, topic string, userId int64, modeGiven string, now *gtime.Time) error {
|
||||
table := tenantTablePrefix(tenantId) + "subscriptions"
|
||||
_, err := tx.Exec(fmt.Sprintf(`INSERT INTO %s(tenant_id,createdat,updatedat,deletedat,userid,topic,modewant,modegiven,private)
|
||||
VALUES(?,?,?,NULL,?,?,?,?,CAST('{}' AS JSON))
|
||||
ON DUPLICATE KEY UPDATE createdat=VALUES(createdat),updatedat=VALUES(updatedat),deletedat=NULL,
|
||||
modewant=VALUES(modewant),modegiven=VALUES(modegiven),delid=0,recvseqid=0,readseqid=0`,
|
||||
now, now, userId, topic, imGroupModeMember, modeGiven)
|
||||
modewant=VALUES(modewant),modegiven=VALUES(modegiven),delid=0,recvseqid=0,readseqid=0`, table),
|
||||
tenantId, now, now, userId, topic, imGroupModeMember, modeGiven)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) upsertGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64, role string, joinSource string, joinedBy int64, now *gtime.Time) error {
|
||||
_, err := tx.Exec(`INSERT INTO im_group_members(topic,user_id,role,member_status,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
func (s *sSysImGroup) upsertGroupMember(ctx context.Context, tx gdb.TX, tenantId int64, topic string, userId int64, role string, joinSource string, joinedBy int64, now *gtime.Time) error {
|
||||
table := tenantTablePrefix(tenantId) + "group_members"
|
||||
_, err := tx.Exec(fmt.Sprintf(`INSERT INTO %s(tenant_id,topic,user_id,role,member_status,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),join_source=VALUES(join_source),
|
||||
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
|
||||
topic, userId, role, consts.ImGroupMemberStatusActive, joinSource, joinedBy, now, 1, now, now)
|
||||
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`, table),
|
||||
tenantId, topic, userId, role, consts.ImGroupMemberStatusActive, joinSource, joinedBy, now, 1, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) recountGroupMembers(ctx context.Context, tx gdb.TX, topic string) error {
|
||||
if _, err := tx.Exec(`UPDATE im_group_settings
|
||||
SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE topic=?`, topic, consts.ImGroupMemberStatusActive, topic); err != nil {
|
||||
func (s *sSysImGroup) recountGroupMembers(ctx context.Context, tx gdb.TX, tenantId int64, topic string) error {
|
||||
membersTable := tenantTablePrefix(tenantId) + "group_members"
|
||||
subscriptionsTable := tenantTablePrefix(tenantId) + "subscriptions"
|
||||
topicsTable := tenantTablePrefix(tenantId) + "topics"
|
||||
if _, err := tx.Exec(fmt.Sprintf(`UPDATE im_group_settings
|
||||
SET member_count=(SELECT COUNT(*) FROM %s WHERE tenant_id=? AND topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND topic=?`, membersTable), tenantId, topic, consts.ImGroupMemberStatusActive, tenantId, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE im_topics
|
||||
SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic=? AND deletedat IS NULL),updatedat=CURRENT_TIMESTAMP(3)
|
||||
WHERE name=?`, topic, topic)
|
||||
_, err := tx.Exec(fmt.Sprintf(`UPDATE %s
|
||||
SET subcnt=(SELECT COUNT(*) FROM %s WHERE tenant_id=? AND topic=? AND deletedat IS NULL),updatedat=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND name=?`, topicsTable, subscriptionsTable), tenantId, topic, tenantId, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).GetForTenant(ctx, "/internal/admin/workbench/mailbox-config", nil, tenant.Id, tenant.Code, 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).PostForTenant(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总经理信箱配置失败,请稍后重试!")
|
||||
}, tenant.Id, tenant.Code, 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,297 @@
|
||||
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).
|
||||
Fields(
|
||||
"t.id tenant_id", "t.code tenant_code", "t.name tenant_name",
|
||||
"t."+imTenantMessageEncryptionColumn+" 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("t."+imTenantMessageEncryptionColumn, *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.ImTenant.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImTenant.Columns().Id, tenant.Id).
|
||||
Data(g.Map{dao.ImTenant.Columns().MessageEncryptionEnabled: in.Enabled, dao.ImTenant.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", dao.ImTenant.Table(), 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.ImTenant.Table()).Safe().Ctx(ctx).
|
||||
Fields(dao.ImTenant.Columns().MessageEncryptionEnabled).Where(dao.ImTenant.Columns().Id, 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
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
@@ -43,6 +45,38 @@ func policyJSON(value *gjson.Json, fallback string) string {
|
||||
return value.String()
|
||||
}
|
||||
|
||||
// imPolicyTemplateTable 安全策略模板表(平台级单行,新建商户初始策略来源)
|
||||
const imPolicyTemplateTable = "im_security_policy_template"
|
||||
|
||||
// loadPolicyTemplateRow 读取安全策略模板单行(取最小 id),返回 nil 表示模板未初始化。
|
||||
func loadPolicyTemplateRow(model *gdb.Model) (gdb.Record, error) {
|
||||
row, err := model.Order("id ASC").Limit(1).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// templateCurfewRules 解析模板行 curfew_rules JSON 为宵禁规则输入列表
|
||||
func templateCurfewRules(tpl gdb.Record) []*sysin.ImMessageCurfewRuleInp {
|
||||
rules := make([]*sysin.ImMessageCurfewRuleInp, 0)
|
||||
for _, item := range gjson.New(tpl["curfew_rules"].String()).Array() {
|
||||
j := gjson.New(item)
|
||||
rules = append(rules, &sysin.ImMessageCurfewRuleInp{
|
||||
RuleCode: j.Get("ruleCode").String(), RuleName: j.Get("ruleName").String(), DateType: j.Get("dateType").String(),
|
||||
CustomDates: gjson.New(j.Get("customDates").String()), Weekdays: gjson.New(j.Get("weekdays").String()),
|
||||
Timezone: j.Get("timezone").String(), StartTime: j.Get("startTime").String(), EndTime: j.Get("endTime").String(),
|
||||
Scope: j.Get("scope").String(),
|
||||
WhitelistUserIds: gjson.New(j.Get("whitelistUserIds").String()), WhitelistGroupIds: gjson.New(j.Get("whitelistGroupIds").String()),
|
||||
Message: j.Get("message").String(), Status: j.Get("status").Int(), Sort: j.Get("sort").Int(),
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func publishImPolicyEvent(ctx context.Context, tenantID int64, tenantCode, eventType, group string, version uint) error {
|
||||
return imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{
|
||||
TenantID: tenantID,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package sys
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/library/storager"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func loadImPublicUploads(ctx context.Context, fileIds []string) (files []*sysin.ImPublicUploadModel, err error) {
|
||||
fileIds = compactStringSlice(fileIds)
|
||||
if len(fileIds) == 0 {
|
||||
return []*sysin.ImPublicUploadModel{}, nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(fileIds))
|
||||
args := make([]any, 0, len(fileIds))
|
||||
for _, fileId := range fileIds {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, fileId)
|
||||
}
|
||||
query := `SELECT id,file_id,purpose,mime_type,size,etag,location,expires_at,created_at,uploaded_at
|
||||
FROM im_public_uploads WHERE file_id IN (` + strings.Join(placeholders, ",") + `) ORDER BY id ASC`
|
||||
rows, err := g.DB().GetAll(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取公共上传文件信息失败,请稍后重试!")
|
||||
}
|
||||
if err = rows.Structs(&files); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析公共上传文件信息失败,请稍后重试!")
|
||||
}
|
||||
|
||||
drive := ""
|
||||
if conf, confErr := service.SysConfig().GetUpload(ctx); confErr == nil && conf != nil {
|
||||
drive = conf.Drive
|
||||
}
|
||||
for _, file := range files {
|
||||
file.Url = storager.LastUrl(ctx, file.Location, drive)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func firstImPublicUpload(ctx context.Context, fileId string) (file *sysin.ImPublicUploadModel, err error) {
|
||||
files, err := loadImPublicUploads(ctx, []string{fileId})
|
||||
if err != nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
return files[0], nil
|
||||
}
|
||||
|
||||
func parseJSONStringSlice(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ids []string
|
||||
if err := gjson.DecodeTo([]byte(raw), &ids); err == nil && len(ids) > 0 {
|
||||
return compactStringSlice(ids)
|
||||
}
|
||||
return compactStringSlice([]string{raw})
|
||||
}
|
||||
|
||||
func compactStringSlice(values []string) []string {
|
||||
res := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
res = append(res, value)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (s *sSysImRegistration) seedConfig(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
func (s *sSysImRegistration) verifySuper(ctx context.Context) error {
|
||||
if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) {
|
||||
if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
|
||||
return gerror.New("仅平台超级管理员可操作IM注册配置")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -65,8 +65,34 @@ func (s *sSysImSecurityPolicy) View(ctx context.Context, in *sysin.ImSecurityPol
|
||||
err = baseModel.Where(c.TenantId, tenantID).
|
||||
Where(c.PublishStatus, consts.ImSecurityPublishPublished).
|
||||
OrderDesc(c.PolicyVersion).Scan(&res)
|
||||
if err == nil && res == nil {
|
||||
err = baseModel.Where(c.TenantId, tenantID).OrderDesc(c.PolicyVersion).Scan(&res)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取安全策略失败")
|
||||
}
|
||||
if res == nil {
|
||||
// 无自建策略时回落默认模板展示,保存后成为本商户自己的策略
|
||||
tpl, tplErr := loadPolicyTemplateRow(imtenant.RegistryDB().Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if tplErr != nil {
|
||||
return nil, gerror.Wrap(tplErr, "读取安全策略模板失败")
|
||||
}
|
||||
if tpl != nil {
|
||||
res = &sysin.ImSecurityPolicyModel{
|
||||
TenantId: tenantID,
|
||||
PolicyName: tpl["policy_name"].String(),
|
||||
PasswordPolicy: tpl["password_policy"].String(),
|
||||
LoginRateLimit: tpl["login_rate_limit"].String(),
|
||||
SensitiveWordPolicy: tpl["sensitive_word_policy"].String(),
|
||||
FileReviewPolicy: tpl["file_review_policy"].String(),
|
||||
RealnamePolicy: tpl["realname_policy"].String(),
|
||||
RiskChallengePolicy: tpl["risk_challenge_policy"].String(),
|
||||
GlobalFriendEnabled: tpl["global_friend_enabled"].Int(),
|
||||
FriendDisabledPolicy: tpl["friend_disabled_policy"].String(),
|
||||
MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(),
|
||||
}
|
||||
for _, rule := range templateCurfewRules(tpl) {
|
||||
res.CurfewRules = append(res.CurfewRules, &sysin.ImMessageCurfewRuleModel{RuleCode: rule.RuleCode, RuleName: rule.RuleName, DateType: rule.DateType, CustomDates: rule.CustomDates.String(), Weekdays: rule.Weekdays.String(), Timezone: rule.Timezone, StartTime: rule.StartTime, EndTime: rule.EndTime, Scope: rule.Scope, WhitelistUserIds: rule.WhitelistUserIds.String(), WhitelistGroupIds: rule.WhitelistGroupIds.String(), Message: rule.Message, Status: rule.Status, Sort: rule.Sort})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -88,59 +114,62 @@ func (s *sSysImSecurityPolicy) curfewRules(ctx context.Context, policyID int64)
|
||||
return
|
||||
}
|
||||
|
||||
// Save 保存安全策略并立即生效:每租户仅一条当前生效记录(单记录模型,无草稿/发布工作流)。
|
||||
// 已有记录则原地更新(版本号自增仅作审计),无则创建;同步 im_tenant 冗余开关并投递策略变更事件。
|
||||
func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPolicySaveInp) (id int64, err error) {
|
||||
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
|
||||
}
|
||||
if tenantID == 0 {
|
||||
// 默认模板不可通过商户策略保存入口修改
|
||||
return 0, gerror.New("默认模板不可修改,请选择具体商户")
|
||||
}
|
||||
db := imtenant.RegistryDB()
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
rc := dao.ImMessageCurfewRules.Columns()
|
||||
tc := dao.ImTenant.Columns()
|
||||
operator := contexts.GetUserId(ctx)
|
||||
now := gtime.Now()
|
||||
version := uint(1)
|
||||
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
version := uint(0)
|
||||
if in.Id > 0 {
|
||||
current, readErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if current.IsEmpty() || current[c.TenantId].Int64() != tenantID {
|
||||
return gerror.New("安全策略不存在")
|
||||
}
|
||||
if current[c.PublishStatus].String() != consts.ImSecurityPublishDraft {
|
||||
return gerror.New("只有草稿状态的安全策略可以编辑")
|
||||
}
|
||||
version = current[c.PolicyVersion].Uint()
|
||||
id = in.Id
|
||||
} else {
|
||||
maxValue, maxErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.TenantId, tenantID).Max(c.PolicyVersion)
|
||||
if maxErr != nil {
|
||||
return maxErr
|
||||
}
|
||||
version = uint(maxValue) + 1
|
||||
current, readErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
|
||||
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
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}
|
||||
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
|
||||
}
|
||||
} else {
|
||||
data := g.Map{c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishPublished, 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.LoginRateLimit == nil {
|
||||
// 未提交登录失败策略时保留原值(登录失败锁定由独立配置入口管理,避免保存时清空配置)
|
||||
delete(data, c.LoginRateLimit)
|
||||
}
|
||||
if current.IsEmpty() {
|
||||
version = 1
|
||||
data[c.TenantId] = tenantID
|
||||
data[c.PolicyVersion] = version
|
||||
data[c.CreatedAt] = now
|
||||
data[c.CreatedBy] = operator
|
||||
result, insertErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Data(data).Insert()
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
id, insertErr = result.LastInsertId()
|
||||
if insertErr != nil {
|
||||
if id, insertErr = result.LastInsertId(); insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
} else {
|
||||
id = current[c.Id].Int64()
|
||||
version = current[c.PolicyVersion].Uint() + 1
|
||||
data[c.PolicyVersion] = version
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, id).Data(data).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
}
|
||||
if _, deleteErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Where(rc.PolicyId, id).Delete(); deleteErr != nil {
|
||||
// 规则整体重建:uk_im_message_curfew_rule 唯一键含 tenant_id+rule_code,软删行仍占键会冲突,必须硬删
|
||||
if _, deleteErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Where(rc.PolicyId, id).Unscoped().Delete(); deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
for _, rule := range in.CurfewRules {
|
||||
@@ -149,58 +178,41 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
|
||||
return insertErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "保存安全策略草稿失败")
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_save", "im_security_policy", id, "success", g.Map{"global_friend_enabled": in.GlobalFriendEnabled, "message_curfew_enabled": in.MessageCurfewEnabled})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *sSysImSecurityPolicy) Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
db := imtenant.RegistryDB()
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
var row gdb.Record
|
||||
var err error
|
||||
if row, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return gerror.New("安全策略不存在")
|
||||
}
|
||||
if row[c.PublishStatus].String() != consts.ImSecurityPublishDraft {
|
||||
return gerror.New("只有草稿状态可以发布")
|
||||
}
|
||||
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operator := contexts.GetUserId(ctx)
|
||||
now := gtime.Now()
|
||||
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).WhereNot(c.Id, in.Id).WhereNull(c.DeletedAt).Data(g.Map{c.PublishStatus: consts.ImSecurityPublishArchived, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(g.Map{c.PublishStatus: consts.ImSecurityPublishPublished, c.PublishedAt: now, c.PublishedBy: operator, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if tenantID > 0 {
|
||||
tc := dao.ImTenantConfig.Columns()
|
||||
if _, updateErr := tx.Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).Where(tc.TenantId, tenantID).Data(g.Map{tc.SecurityPolicyVersion: row[c.PolicyVersion].Uint(), tc.GlobalFriendEnabled: row[c.GlobalFriendEnabled].Int(), tc.MessageCurfewEnabled: row[c.MessageCurfewEnabled].Int(), tc.UpdatedAt: now, tc.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
// 上游已将商户配置并入 im_tenant 表,此处同步冗余开关与策略版本。
|
||||
// updated_at 显式自赋值:抑制 gf 自动填充与 ON UPDATE CURRENT_TIMESTAMP,策略保存不 touch 商户行的更新人/时间
|
||||
if _, cfgErr := tx.Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(tc.Id, tenantID).
|
||||
Data(g.Map{tc.SecurityPolicyVersion: version, tc.GlobalFriendEnabled: in.GlobalFriendEnabled, tc.MessageCurfewEnabled: in.MessageCurfewEnabled, tc.UpdatedAt: gdb.Raw("updated_at")}).Update(); cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "发布安全策略失败")
|
||||
return 0, gerror.Wrap(err, "保存安全策略失败")
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_publish", "im_security_policy", in.Id, "success", g.Map{"reason": in.Reason, "version": row[c.PolicyVersion].Uint()})
|
||||
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", row[c.PolicyVersion].Uint())
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_save", "im_security_policy", id, "success", g.Map{"version": version, "global_friend_enabled": in.GlobalFriendEnabled, "message_curfew_enabled": in.MessageCurfewEnabled})
|
||||
// 保存即生效:投递策略变更事件,IM 端消费后失效策略缓存并回源拉取新配置。
|
||||
// 事件已持久化到 im_admin_outbox,立即投递失败仅告警+审计、由 outbox 重试续投,不阻塞已生效的保存结果
|
||||
if pubErr := publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version); pubErr != nil {
|
||||
g.Log().Warningf(ctx, "IM策略事件投递失败(outbox将重试): tenant=%d version=%d err=%v", tenantID, version, pubErr)
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_policy_event_publish", "im_security_policy", id, "failed", g.Map{"error": pubErr.Error(), "version": version})
|
||||
}
|
||||
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) RestoreDefault(ctx context.Context, in *sysin.ImSecurityPolicyRestoreInp) (int64, error) {
|
||||
@@ -211,33 +223,123 @@ func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImS
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
var source *sysin.ImSecurityPolicyModel
|
||||
err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Fields(securityPolicyFields("")...).Where(c.TenantId, 0).
|
||||
Where(c.PublishStatus, consts.ImSecurityPublishPublished).WhereNull(c.DeletedAt).
|
||||
OrderDesc(c.PolicyVersion).Scan(&source)
|
||||
// 读取安全策略模板,将模板内容覆盖为当前商户的策略(复制后商户可自行修改)
|
||||
tpl, err := loadPolicyTemplateRow(imtenant.RegistryDB().Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "读取全局默认安全策略失败")
|
||||
return 0, gerror.Wrap(err, "读取安全策略模板失败")
|
||||
}
|
||||
if source == nil {
|
||||
return 0, gerror.New("全局默认安全策略不存在")
|
||||
if tpl == nil {
|
||||
return 0, gerror.New("安全策略模板未初始化")
|
||||
}
|
||||
source.CurfewRules, err = s.curfewRules(ctx, source.Id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rules := make([]*sysin.ImMessageCurfewRuleInp, 0, len(source.CurfewRules))
|
||||
for _, rule := range source.CurfewRules {
|
||||
rules = append(rules, &sysin.ImMessageCurfewRuleInp{RuleCode: rule.RuleCode, RuleName: rule.RuleName, DateType: rule.DateType, CustomDates: gjson.New(rule.CustomDates), Weekdays: gjson.New(rule.Weekdays), Timezone: rule.Timezone, StartTime: rule.StartTime, EndTime: rule.EndTime, Scope: rule.Scope, WhitelistUserIds: gjson.New(rule.WhitelistUserIds), WhitelistGroupIds: gjson.New(rule.WhitelistGroupIds), Message: rule.Message, Status: rule.Status, Sort: rule.Sort})
|
||||
}
|
||||
id, err := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "恢复默认安全策略", PasswordPolicy: gjson.New(source.PasswordPolicy), LoginRateLimit: gjson.New(source.LoginRateLimit), SensitiveWordPolicy: gjson.New(source.SensitiveWordPolicy), FileReviewPolicy: gjson.New(source.FileReviewPolicy), RealnamePolicy: gjson.New(source.RealnamePolicy), RiskChallengePolicy: gjson.New(source.RiskChallengePolicy), GlobalFriendEnabled: source.GlobalFriendEnabled, FriendDisabledPolicy: gjson.New(source.FriendDisabledPolicy), MessageCurfewEnabled: source.MessageCurfewEnabled, Remark: in.Reason, CurfewRules: rules})
|
||||
id, err := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: tpl["policy_name"].String(), PasswordPolicy: gjson.New(tpl["password_policy"].String()), LoginRateLimit: gjson.New(tpl["login_rate_limit"].String()), SensitiveWordPolicy: gjson.New(tpl["sensitive_word_policy"].String()), FileReviewPolicy: gjson.New(tpl["file_review_policy"].String()), RealnamePolicy: gjson.New(tpl["realname_policy"].String()), RiskChallengePolicy: gjson.New(tpl["risk_challenge_policy"].String()), GlobalFriendEnabled: tpl["global_friend_enabled"].Int(), FriendDisabledPolicy: gjson.New(tpl["friend_disabled_policy"].String()), MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(), Remark: in.Reason, CurfewRules: templateCurfewRules(tpl)})
|
||||
if err == nil {
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_restore_default", "im_security_policy", id, "success", g.Map{"reason": in.Reason})
|
||||
}
|
||||
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})
|
||||
if pubErr := publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version); pubErr != nil {
|
||||
g.Log().Warningf(ctx, "IM策略事件投递失败(outbox将重试): tenant=%d version=%d err=%v", tenantID, version, pubErr)
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_policy_event_publish", "im_security_policy", policyID, "failed", g.Map{"error": pubErr.Error(), "version": version})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 租户尚无策略:按模板复制初始策略(模板未初始化时退回内置默认值),仅覆盖登录失败配置,保存即生效并投递策略变更事件
|
||||
saveInp := &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "默认安全策略", LoginRateLimit: gjson.New(value), GlobalFriendEnabled: 1, MessageCurfewEnabled: 0}
|
||||
tpl, tplErr := loadPolicyTemplateRow(imtenant.RegistryDB().Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if tplErr != nil {
|
||||
return gerror.Wrap(tplErr, "读取安全策略模板失败")
|
||||
}
|
||||
if tpl != nil {
|
||||
saveInp.PolicyName = tpl["policy_name"].String()
|
||||
saveInp.PasswordPolicy = gjson.New(tpl["password_policy"].String())
|
||||
saveInp.SensitiveWordPolicy = gjson.New(tpl["sensitive_word_policy"].String())
|
||||
saveInp.FileReviewPolicy = gjson.New(tpl["file_review_policy"].String())
|
||||
saveInp.RealnamePolicy = gjson.New(tpl["realname_policy"].String())
|
||||
saveInp.RiskChallengePolicy = gjson.New(tpl["risk_challenge_policy"].String())
|
||||
saveInp.GlobalFriendEnabled = tpl["global_friend_enabled"].Int()
|
||||
saveInp.FriendDisabledPolicy = gjson.New(tpl["friend_disabled_policy"].String())
|
||||
saveInp.MessageCurfewEnabled = tpl["message_curfew_enabled"].Int()
|
||||
saveInp.CurfewRules = templateCurfewRules(tpl)
|
||||
}
|
||||
id, saveErr := s.Save(ctx, saveInp)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
_ = 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
|
||||
|
||||
@@ -15,16 +15,22 @@ 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"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type sSysImTenant struct{}
|
||||
|
||||
// imManageRoleKey 商户级IM管理员角色标识
|
||||
const imManageRoleKey = "imManage"
|
||||
|
||||
func NewSysImTenant() *sSysImTenant {
|
||||
return &sSysImTenant{}
|
||||
}
|
||||
@@ -38,10 +44,6 @@ func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gd
|
||||
return handler.Model(imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model {
|
||||
return imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// List 获取IM租户列表
|
||||
func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (list []*sysin.ImTenantListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
@@ -53,7 +55,13 @@ func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (lis
|
||||
dao.ImTenant.Columns().Code,
|
||||
dao.ImTenant.Columns().Name,
|
||||
dao.ImTenant.Columns().TenantDesc,
|
||||
dao.ImTenant.Columns().DbName,
|
||||
dao.ImTenant.Columns().MaxUsers,
|
||||
dao.ImTenant.Columns().MaxGroups,
|
||||
dao.ImTenant.Columns().MaxGroupMembers,
|
||||
dao.ImTenant.Columns().AliyunModerationEnabled,
|
||||
dao.ImTenant.Columns().LocalModerationEnabled,
|
||||
dao.ImTenant.Columns().MessageEncryptionEnabled,
|
||||
dao.ImTenant.Columns().RealnameReviewPolicy,
|
||||
dao.ImTenant.Columns().State,
|
||||
dao.ImTenant.Columns().CreatedBy,
|
||||
dao.ImTenant.Columns().UpdatedBy,
|
||||
@@ -61,6 +69,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,9 +86,6 @@ 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 != "" {
|
||||
mod = mod.WhereLike(dao.ImTenant.Columns().DbName, "%"+in.DbName+"%")
|
||||
}
|
||||
if in.State != nil {
|
||||
mod = mod.Where(dao.ImTenant.Columns().State, *in.State)
|
||||
}
|
||||
@@ -80,71 +93,83 @@ func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (lis
|
||||
mod = mod.WhereBetween(dao.ImTenant.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
|
||||
mod = mod.Page(in.Page, in.PerPage).
|
||||
OrderDesc(dao.ImTenant.Columns().Id).
|
||||
Hook(hook.MemberSummary)
|
||||
orderDesc := false
|
||||
for _, sorter := range in.GetSorters() {
|
||||
if sorter != nil && sorter.ColumnKey == "id" {
|
||||
orderDesc = sorter.Order == "descend"
|
||||
break
|
||||
}
|
||||
}
|
||||
mod = mod.Page(in.Page, in.PerPage)
|
||||
if orderDesc {
|
||||
mod = mod.OrderDesc(dao.ImTenant.Columns().Id)
|
||||
} else {
|
||||
mod = mod.OrderAsc(dao.ImTenant.Columns().Id)
|
||||
}
|
||||
mod = mod.Hook(hook.MemberSummary)
|
||||
|
||||
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
||||
err = gerror.Wrap(err, "获取IM租户列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.fillListConfig(ctx, list); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenantListModel) (err error) {
|
||||
ids := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
ids = append(ids, item.Id)
|
||||
// Add 新增IM租户
|
||||
func (s *sSysImTenant) Add(ctx context.Context, in *sysin.ImTenantEditInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var configs []*entity.ImTenantConfig
|
||||
if err = s.configModel(ctx).
|
||||
WhereIn(dao.ImTenantConfig.Columns().TenantId, ids).
|
||||
Scan(&configs); err != nil {
|
||||
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
|
||||
if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
|
||||
return gerror.New("只有平台管理员可以新增商户")
|
||||
}
|
||||
configMap := make(map[int64]*entity.ImTenantConfig, len(configs))
|
||||
for _, item := range configs {
|
||||
configMap[item.TenantId] = item
|
||||
}
|
||||
|
||||
var tenants []*entity.ImTenant
|
||||
if err = s.Model(ctx).
|
||||
Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().DbDsn).
|
||||
WhereIn(dao.ImTenant.Columns().Id, ids).
|
||||
Scan(&tenants); err != nil {
|
||||
return gerror.Wrap(err, "获取IM租户连接配置失败,请稍后重试!")
|
||||
}
|
||||
dsnMap := make(map[int64]string, len(tenants))
|
||||
for _, item := range tenants {
|
||||
dsnMap[item.Id] = item.DbDsn
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
if conf, ok := configMap[item.Id]; ok {
|
||||
item.MaxUsers = conf.MaxUsers
|
||||
item.MaxGroups = conf.MaxGroups
|
||||
item.MaxGroupMembers = conf.MaxGroupMembers
|
||||
}
|
||||
item.DbDsnConfigured = dsnMap[item.Id] != ""
|
||||
}
|
||||
return
|
||||
in.Id = 0
|
||||
return s.saveTenant(ctx, in)
|
||||
}
|
||||
|
||||
// Edit 修改/新增IM租户
|
||||
// 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
|
||||
requestedState := in.State
|
||||
if isCreate {
|
||||
if requestedState != consts.ImTenantStateActive && requestedState != consts.ImTenantStateSuspended {
|
||||
return gerror.New("新商户创建完成后的状态只能是正常或已停用")
|
||||
}
|
||||
if err = ensureTenantIDSequence(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
in.State = consts.ImTenantStateProvisioning
|
||||
}
|
||||
if err = validateTenantMessageEncryption(ctx, in.Id, in.MessageEncryptionEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
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,23 +177,270 @@ 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().MaxUsers: in.MaxUsers,
|
||||
dao.ImTenant.Columns().MaxGroups: in.MaxGroups,
|
||||
dao.ImTenant.Columns().MaxGroupMembers: in.MaxGroupMembers,
|
||||
dao.ImTenant.Columns().AliyunModerationEnabled: in.AliyunModerationEnabled,
|
||||
dao.ImTenant.Columns().LocalModerationEnabled: in.LocalModerationEnabled,
|
||||
dao.ImTenant.Columns().MessageEncryptionEnabled: in.MessageEncryptionEnabled,
|
||||
dao.ImTenant.Columns().RealnameReviewPolicy: in.RealnameReviewPolicy,
|
||||
dao.ImTenant.Columns().State: in.State,
|
||||
dao.ImTenant.Columns().UpdatedBy: userId,
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
|
||||
in.Id, err = nextTenantID(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.CreatedBy = userId
|
||||
id, err := s.Model(ctx, &handler.Option{FilterAuth: false}).
|
||||
in.UpdatedBy = userId
|
||||
_, err = tx.Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
|
||||
Fields(sysin.ImTenantInsertFields{}).
|
||||
Data(in).InsertAndGetId()
|
||||
Data(in).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "新增IM租户失败,请稍后重试!")
|
||||
}
|
||||
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
|
||||
tenantID = in.Id
|
||||
// 新建商户时复制安全策略模板为本商户初始策略(保存即生效模型)
|
||||
if err = copyDefaultPolicyForTenant(ctx, tx, in.Id, userId); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 新增商户时自动创建绑定的后台管理员账号,失败则补偿回滚商户数据
|
||||
if isCreate {
|
||||
if err = s.createTenantAdminMember(ctx, super, in, tenantID); err != nil {
|
||||
s.rollbackTenantCreation(ctx, tenantID)
|
||||
return err
|
||||
}
|
||||
if err = provisionTenantMessageTables(ctx, tenantID); err != nil {
|
||||
return gerror.Wrap(err, "商户已登记为初始化中,但业务分表创建失败")
|
||||
}
|
||||
if err = initializeTenantWorkbench(ctx, tenantID, in.Code); err != nil {
|
||||
return gerror.Wrap(err, "商户业务分表已创建,但工作台初始化失败")
|
||||
}
|
||||
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).WherePri(tenantID).Data(g.Map{
|
||||
dao.ImTenant.Columns().State: requestedState,
|
||||
dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx),
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "商户分表已创建,但更新商户状态失败")
|
||||
}
|
||||
in.State = requestedState
|
||||
} 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
|
||||
}
|
||||
// 清理建户时复制的安全策略与宵禁规则,避免残留孤儿数据(商户未建成,硬删不留软删垃圾)
|
||||
if _, err = tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImSecurityPolicy.Columns().TenantId, tenantId).Unscoped().Delete(); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImMessageCurfewRules.Columns().TenantId, tenantId).Unscoped().Delete()
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
// copyDefaultPolicyForTenant 新建商户时复制安全策略模板为本商户自己的初始策略。
|
||||
// 模板未初始化时跳过(运行时由内部配置接口的默认值兜底),不阻断建户。
|
||||
func copyDefaultPolicyForTenant(ctx context.Context, tx gdb.TX, tenantID, operator int64) (err error) {
|
||||
tpl, err := loadPolicyTemplateRow(tx.Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取安全策略模板失败")
|
||||
}
|
||||
if tpl == nil {
|
||||
return nil
|
||||
}
|
||||
pc := dao.ImSecurityPolicy.Columns()
|
||||
now := gtime.Now()
|
||||
data := g.Map{
|
||||
pc.TenantId: tenantID,
|
||||
pc.PolicyName: tpl["policy_name"].String(),
|
||||
pc.PolicyVersion: 1,
|
||||
pc.PublishStatus: consts.ImSecurityPublishPublished,
|
||||
pc.PasswordPolicy: tpl["password_policy"].String(),
|
||||
pc.LoginRateLimit: tpl["login_rate_limit"].String(),
|
||||
pc.SensitiveWordPolicy: tpl["sensitive_word_policy"].String(),
|
||||
pc.FileReviewPolicy: tpl["file_review_policy"].String(),
|
||||
pc.RealnamePolicy: tpl["realname_policy"].String(),
|
||||
pc.RiskChallengePolicy: tpl["risk_challenge_policy"].String(),
|
||||
pc.GlobalFriendEnabled: tpl["global_friend_enabled"].Int(),
|
||||
pc.FriendDisabledPolicy: tpl["friend_disabled_policy"].String(),
|
||||
pc.MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(),
|
||||
pc.Remark: "由默认模板复制",
|
||||
pc.PublishedAt: now,
|
||||
pc.PublishedBy: operator,
|
||||
pc.CreatedAt: now,
|
||||
pc.CreatedBy: operator,
|
||||
pc.UpdatedAt: now,
|
||||
pc.UpdatedBy: operator,
|
||||
}
|
||||
result, err := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Data(data).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "复制安全策略模板失败")
|
||||
}
|
||||
policyID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步复制模板宵禁规则
|
||||
rc := dao.ImMessageCurfewRules.Columns()
|
||||
for _, rule := range templateCurfewRules(tpl) {
|
||||
ruleData := g.Map{
|
||||
rc.TenantId: tenantID,
|
||||
rc.PolicyId: policyID,
|
||||
rc.RuleCode: rule.RuleCode,
|
||||
rc.RuleName: rule.RuleName,
|
||||
rc.DateType: rule.DateType,
|
||||
rc.CustomDates: policyJSON(rule.CustomDates, "[]"),
|
||||
rc.Weekdays: policyJSON(rule.Weekdays, "[]"),
|
||||
rc.Timezone: rule.Timezone,
|
||||
rc.StartTime: rule.StartTime,
|
||||
rc.EndTime: rule.EndTime,
|
||||
rc.Scope: rule.Scope,
|
||||
rc.WhitelistUserIds: policyJSON(rule.WhitelistUserIds, "[]"),
|
||||
rc.WhitelistGroupIds: policyJSON(rule.WhitelistGroupIds, "[]"),
|
||||
rc.Message: rule.Message,
|
||||
rc.Status: rule.Status,
|
||||
rc.Sort: rule.Sort,
|
||||
rc.CreatedAt: now,
|
||||
rc.CreatedBy: operator,
|
||||
rc.UpdatedAt: now,
|
||||
rc.UpdatedBy: operator,
|
||||
}
|
||||
if _, err = tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Data(ruleData).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "复制模板宵禁规则失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) {
|
||||
@@ -186,48 +458,20 @@ 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) {
|
||||
var conf *entity.ImTenantConfig
|
||||
if err = s.configModel(ctx).
|
||||
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).
|
||||
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,
|
||||
}
|
||||
if conf != nil && conf.Id > 0 {
|
||||
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
|
||||
if _, err = s.configModel(ctx).
|
||||
Fields(
|
||||
dao.ImTenantConfig.Columns().MaxUsers,
|
||||
dao.ImTenantConfig.Columns().MaxGroups,
|
||||
dao.ImTenantConfig.Columns().MaxGroupMembers,
|
||||
dao.ImTenantConfig.Columns().UpdatedBy,
|
||||
).
|
||||
WherePri(conf.Id).Data(data).Update(); err != nil {
|
||||
return gerror.Wrap(err, "修改IM租户配置失败,请稍后重试!")
|
||||
func validateTenantMessageEncryption(ctx context.Context, tenantId int64, enabled int) error {
|
||||
if enabled == 1 {
|
||||
count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantId).
|
||||
Where("status", consts.ImMessageKeyStatusActive).
|
||||
Count()
|
||||
if queryErr != nil {
|
||||
return gerror.Wrap(queryErr, "检查商户消息密钥失败")
|
||||
}
|
||||
if count == 0 {
|
||||
return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data[dao.ImTenantConfig.Columns().TenantId] = tenantId
|
||||
data[dao.ImTenantConfig.Columns().CreatedBy] = userId
|
||||
if _, err = s.configModel(ctx).
|
||||
Fields(
|
||||
dao.ImTenantConfig.Columns().TenantId,
|
||||
dao.ImTenantConfig.Columns().MaxUsers,
|
||||
dao.ImTenantConfig.Columns().MaxGroups,
|
||||
dao.ImTenantConfig.Columns().MaxGroupMembers,
|
||||
dao.ImTenantConfig.Columns().CreatedBy,
|
||||
).
|
||||
Data(data).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "新增IM租户配置失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// View 获取IM租户指定信息
|
||||
@@ -235,6 +479,9 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if err = ensureImTenantTargetAllowed(ctx, in.Id); err != nil {
|
||||
return
|
||||
}
|
||||
if err = s.Model(ctx).WherePri(in.Id).Hook(hook.MemberSummary).Scan(&res); err != nil {
|
||||
err = gerror.Wrap(err, "获取IM租户信息失败,请稍后重试!")
|
||||
return
|
||||
@@ -243,19 +490,25 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
|
||||
return
|
||||
}
|
||||
|
||||
var conf *entity.ImTenantConfig
|
||||
if err = s.configModel(ctx).
|
||||
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
|
||||
Scan(&conf); err != nil {
|
||||
err = gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
|
||||
return
|
||||
// 查询商户自动创建的管理员账号(编辑弹窗展示)
|
||||
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 conf != nil {
|
||||
res.MaxUsers = conf.MaxUsers
|
||||
res.MaxGroups = conf.MaxGroups
|
||||
res.MaxGroupMembers = conf.MaxGroupMembers
|
||||
}
|
||||
res.DbDsnConfigured = res.DbDsn != ""
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,6 +517,14 @@ func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp)
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if in.State == consts.ImTenantStateActive {
|
||||
if err = provisionTenantMessageTables(ctx, in.Id); err != nil {
|
||||
return gerror.Wrap(err, "启用商户前创建业务分表失败")
|
||||
}
|
||||
if err = initializeTenantWorkbench(ctx, in.Id, ""); err != nil {
|
||||
return gerror.Wrap(err, "启用商户前初始化工作台失败")
|
||||
}
|
||||
}
|
||||
if _, err = s.Model(ctx).WherePri(in.Id).Data(g.Map{
|
||||
dao.ImTenant.Columns().State: in.State,
|
||||
dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx),
|
||||
@@ -278,6 +539,9 @@ func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (r
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if err = ensureImTenantTargetAllowed(ctx, in.Id); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var tenant *entity.ImTenant
|
||||
if err = s.Model(ctx).WherePri(in.Id).Scan(&tenant); err != nil {
|
||||
@@ -288,12 +552,10 @@ func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (r
|
||||
}
|
||||
|
||||
res = &sysin.ImTenantUsageModel{
|
||||
Id: tenant.Id,
|
||||
Code: tenant.Code,
|
||||
Name: tenant.Name,
|
||||
DbName: tenant.DbName,
|
||||
DbDsnConfigured: tenant.DbDsn != "",
|
||||
Warnings: []string{},
|
||||
Id: tenant.Id,
|
||||
Code: tenant.Code,
|
||||
Name: tenant.Name,
|
||||
Warnings: []string{},
|
||||
}
|
||||
|
||||
db, _, dbErr := imtenant.DB(ctx, tenant.Id)
|
||||
@@ -310,18 +572,36 @@ func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (r
|
||||
res.DbReachable = true
|
||||
|
||||
res.SchemaVersion = s.queryScalarString(ctx, db, "kvmeta.version", "SELECT value FROM kvmeta WHERE key_='version' LIMIT 1", &res.Warnings)
|
||||
res.UserCount = s.queryScalarInt64(ctx, db, "im_users.total", "SELECT COUNT(*) FROM im_users", &res.Warnings)
|
||||
res.ActiveUserCount = s.queryScalarInt64(ctx, db, "im_users.active", "SELECT COUNT(*) FROM im_users WHERE state=0", &res.Warnings)
|
||||
res.GroupCount = s.queryScalarInt64(ctx, db, "im_group_settings", "SELECT COUNT(*) FROM im_group_settings WHERE group_status<>'dissolved'", &res.Warnings)
|
||||
res.DeviceCount = s.queryScalarInt64(ctx, db, "im_devices", "SELECT COUNT(*) FROM im_devices", &res.Warnings)
|
||||
res.PushTokenCount = s.queryScalarInt64(ctx, db, "im_device_push_tokens", "SELECT COUNT(*) FROM im_device_push_tokens", &res.Warnings)
|
||||
res.InviteUseCount = s.queryScalarInt64(ctx, db, "im_registration_invite_uses", "SELECT COUNT(*) FROM im_registration_invite_uses", &res.Warnings)
|
||||
res.OutboxPendingCount = s.queryScalarInt64(ctx, db, "im_business_events_outbox", "SELECT COUNT(*) FROM im_business_events_outbox WHERE status='pending'", &res.Warnings)
|
||||
tenantId := tenant.Id
|
||||
res.UserCount = s.queryScalarInt64(ctx, db, "im_users.total", "SELECT COUNT(*) FROM im_users WHERE tenant_id=?", &res.Warnings, tenantId)
|
||||
res.ActiveUserCount = s.queryScalarInt64(ctx, db, "im_users.active", "SELECT COUNT(*) FROM im_users WHERE tenant_id=? AND state=0", &res.Warnings, tenantId)
|
||||
res.GroupCount = s.queryScalarInt64(ctx, db, "im_group_settings", "SELECT COUNT(*) FROM im_group_settings WHERE tenant_id=? AND group_status<>'dissolved'", &res.Warnings, tenantId)
|
||||
res.DeviceCount = s.queryScalarInt64(ctx, db, "im_devices", "SELECT COUNT(*) FROM im_devices WHERE tenant_id=?", &res.Warnings, tenantId)
|
||||
res.PushTokenCount = s.queryScalarInt64(ctx, db, "im_device_push_tokens", "SELECT COUNT(*) FROM im_device_push_tokens WHERE tenant_id=?", &res.Warnings, tenantId)
|
||||
res.InviteUseCount = s.queryScalarInt64(ctx, db, "im_registration_invite_uses", "SELECT COUNT(*) FROM im_registration_invite_uses WHERE tenant_id=?", &res.Warnings, tenantId)
|
||||
res.OutboxPendingCount = s.queryScalarInt64(ctx, db, "im_business_events_outbox", "SELECT COUNT(*) FROM im_business_events_outbox WHERE tenant_id=? AND status='pending'", &res.Warnings, tenantId)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) queryScalarInt64(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string) int64 {
|
||||
value, err := db.GetValue(ctx, sql)
|
||||
func ensureImTenantTargetAllowed(ctx context.Context, tenantId int64) error {
|
||||
if isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
|
||||
return nil
|
||||
}
|
||||
if tenantId <= 0 {
|
||||
return gerror.New("商户ID不能为空")
|
||||
}
|
||||
boundTenantId := contexts.GetTenantId(ctx)
|
||||
if boundTenantId <= 0 {
|
||||
return gerror.New("当前管理员未关联IM租户")
|
||||
}
|
||||
if tenantId != boundTenantId {
|
||||
return gerror.New("无权访问其它商户")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) queryScalarInt64(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string, args ...any) int64 {
|
||||
value, err := db.GetValue(ctx, sql, args...)
|
||||
if err != nil {
|
||||
*warnings = append(*warnings, label+": "+err.Error())
|
||||
return 0
|
||||
@@ -396,6 +676,12 @@ func (s *sSysImTenant) Restore(ctx context.Context, in *sysin.ImTenantLifecycleI
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if err = provisionTenantMessageTables(ctx, in.Id); err != nil {
|
||||
return gerror.Wrap(err, "恢复商户前创建业务分表失败")
|
||||
}
|
||||
if err = initializeTenantWorkbench(ctx, in.Id, ""); err != nil {
|
||||
return gerror.Wrap(err, "恢复商户前初始化工作台失败")
|
||||
}
|
||||
if _, err = s.Model(ctx).
|
||||
WherePri(in.Id).
|
||||
WhereIn(dao.ImTenant.Columns().State, []int{consts.ImTenantStateSuspended, consts.ImTenantStateDeleting}).
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package sys
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sSysImTenantApplication struct{}
|
||||
|
||||
func NewSysImTenantApplication() *sSysImTenantApplication {
|
||||
return &sSysImTenantApplication{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterSysImTenantApplication(NewSysImTenantApplication())
|
||||
}
|
||||
|
||||
// List 获取企业注册申请列表
|
||||
func (s *sSysImTenantApplication) List(ctx context.Context, in *sysin.ImTenantApplicationListInp) (list []*sysin.ImTenantApplicationListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
where, args := s.buildWhere(in)
|
||||
totalValue, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM im_tenant_applications "+where, args...)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取企业注册申请总数失败,请稍后重试!")
|
||||
}
|
||||
totalCount = totalValue.Int()
|
||||
|
||||
page, perPage := normalizePage(in.Page, in.PerPage)
|
||||
query := `SELECT id,application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,
|
||||
status,estimated_review_hour,submitted_at,created_at,updated_at
|
||||
FROM im_tenant_applications ` + where + ` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`
|
||||
queryArgs := append(append([]any{}, args...), perPage, (page-1)*perPage)
|
||||
rows, err := g.DB().GetAll(ctx, query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取企业注册申请列表失败,请稍后重试!")
|
||||
}
|
||||
if err = rows.Structs(&list); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "解析企业注册申请列表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImTenantApplication) buildWhere(in *sysin.ImTenantApplicationListInp) (string, []any) {
|
||||
parts := []string{"1=1"}
|
||||
args := []any{}
|
||||
if in.ApplicationId != "" {
|
||||
parts = append(parts, "application_id LIKE ?")
|
||||
args = append(args, "%"+in.ApplicationId+"%")
|
||||
}
|
||||
if in.CreditCode != "" {
|
||||
parts = append(parts, "credit_code LIKE ?")
|
||||
args = append(args, "%"+in.CreditCode+"%")
|
||||
}
|
||||
if in.EnterpriseEmail != "" {
|
||||
parts = append(parts, "enterprise_email LIKE ?")
|
||||
args = append(args, "%"+in.EnterpriseEmail+"%")
|
||||
}
|
||||
if in.TenantCodeHint != "" {
|
||||
parts = append(parts, "tenant_code_hint LIKE ?")
|
||||
args = append(args, "%"+in.TenantCodeHint+"%")
|
||||
}
|
||||
if in.Status != "" {
|
||||
parts = append(parts, "status=?")
|
||||
args = append(args, in.Status)
|
||||
}
|
||||
if in.Keyword != "" {
|
||||
like := "%" + in.Keyword + "%"
|
||||
parts = append(parts, "(application_id LIKE ? OR credit_code LIKE ? OR enterprise_email LIKE ? OR tenant_code_hint LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
parts = append(parts, "created_at BETWEEN ? AND ?")
|
||||
args = append(args, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
return "WHERE " + strings.Join(parts, " AND "), args
|
||||
}
|
||||
|
||||
// View 获取企业注册申请指定信息
|
||||
func (s *sSysImTenantApplication) View(ctx context.Context, in *sysin.ImTenantApplicationViewInp) (res *sysin.ImTenantApplicationViewModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
query := `SELECT id,application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,
|
||||
CAST(client_context AS CHAR) client_context,idempotency_key,status,estimated_review_hour,
|
||||
submitted_at,created_at,updated_at FROM im_tenant_applications WHERE id=? LIMIT 1`
|
||||
row, err := g.DB().GetOne(ctx, query, in.Id)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取企业注册申请信息失败,请稍后重试!")
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
if err = row.Struct(&res); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析企业注册申请信息失败,请稍后重试!")
|
||||
}
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
res.LicenseFile, err = firstImPublicUpload(ctx, res.LicenseFileId)
|
||||
return
|
||||
}
|
||||
|
||||
func normalizePage(page, perPage int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package sys
|
||||
// @Link https://github.com/bufanyun/hotgo
|
||||
// @Copyright Copyright (c) 2026 HotGo
|
||||
// @Author Ms <133814250@qq.com>
|
||||
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sSysImTenantEntryFeedback struct{}
|
||||
|
||||
func NewSysImTenantEntryFeedback() *sSysImTenantEntryFeedback {
|
||||
return &sSysImTenantEntryFeedback{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterSysImTenantEntryFeedback(NewSysImTenantEntryFeedback())
|
||||
}
|
||||
|
||||
// List 获取企业入口反馈列表
|
||||
func (s *sSysImTenantEntryFeedback) List(ctx context.Context, in *sysin.ImTenantEntryFeedbackListInp) (list []*sysin.ImTenantEntryFeedbackListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
where, args := s.buildWhere(in)
|
||||
totalValue, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM im_tenant_entry_feedbacks "+where, args...)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取企业入口反馈总数失败,请稍后重试!")
|
||||
}
|
||||
totalCount = totalValue.Int()
|
||||
|
||||
page, perPage := normalizePage(in.Page, in.PerPage)
|
||||
query := `SELECT id,feedback_id,description,contact,tenant_code_hint,status,submitted_at,created_at,updated_at
|
||||
FROM im_tenant_entry_feedbacks ` + where + ` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`
|
||||
queryArgs := append(append([]any{}, args...), perPage, (page-1)*perPage)
|
||||
rows, err := g.DB().GetAll(ctx, query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取企业入口反馈列表失败,请稍后重试!")
|
||||
}
|
||||
if err = rows.Structs(&list); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "解析企业入口反馈列表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImTenantEntryFeedback) buildWhere(in *sysin.ImTenantEntryFeedbackListInp) (string, []any) {
|
||||
parts := []string{"1=1"}
|
||||
args := []any{}
|
||||
if in.FeedbackId != "" {
|
||||
parts = append(parts, "feedback_id LIKE ?")
|
||||
args = append(args, "%"+in.FeedbackId+"%")
|
||||
}
|
||||
if in.Contact != "" {
|
||||
parts = append(parts, "contact LIKE ?")
|
||||
args = append(args, "%"+in.Contact+"%")
|
||||
}
|
||||
if in.TenantCodeHint != "" {
|
||||
parts = append(parts, "tenant_code_hint LIKE ?")
|
||||
args = append(args, "%"+in.TenantCodeHint+"%")
|
||||
}
|
||||
if in.Status != "" {
|
||||
parts = append(parts, "status=?")
|
||||
args = append(args, in.Status)
|
||||
}
|
||||
if in.Keyword != "" {
|
||||
like := "%" + in.Keyword + "%"
|
||||
parts = append(parts, "(feedback_id LIKE ? OR description LIKE ? OR contact LIKE ? OR tenant_code_hint LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
parts = append(parts, "created_at BETWEEN ? AND ?")
|
||||
args = append(args, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
return "WHERE " + strings.Join(parts, " AND "), args
|
||||
}
|
||||
|
||||
// View 获取企业入口反馈指定信息
|
||||
func (s *sSysImTenantEntryFeedback) View(ctx context.Context, in *sysin.ImTenantEntryFeedbackViewInp) (res *sysin.ImTenantEntryFeedbackViewModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
query := `SELECT id,feedback_id,description,contact,tenant_code_hint,
|
||||
CAST(screenshot_file_ids AS CHAR) screenshot_file_ids,CAST(client_context AS CHAR) client_context,
|
||||
idempotency_key,status,submitted_at,created_at,updated_at
|
||||
FROM im_tenant_entry_feedbacks WHERE id=? LIMIT 1`
|
||||
row, err := g.DB().GetOne(ctx, query, in.Id)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取企业入口反馈信息失败,请稍后重试!")
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
if err = row.Struct(&res); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析企业入口反馈信息失败,请稍后重试!")
|
||||
}
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
res.ScreenshotFiles, err = loadImPublicUploads(ctx, parseJSONStringSlice(res.ScreenshotFileIds))
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const imTenantIDSequenceTable = "im_tenant_id_sequence"
|
||||
|
||||
func ensureTenantIDSequence(ctx context.Context) error {
|
||||
db := imtenant.RegistryDB()
|
||||
if _, err := db.Exec(ctx, `CREATE TABLE IF NOT EXISTS im_tenant_id_sequence (
|
||||
sequence_name VARCHAR(32) NOT NULL,
|
||||
next_id BIGINT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(sequence_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil {
|
||||
return gerror.Wrap(err, "初始化商户编号序列失败")
|
||||
}
|
||||
_, err := db.Exec(ctx, `INSERT INTO im_tenant_id_sequence(sequence_name,next_id)
|
||||
SELECT 'tenant',COALESCE(MAX(id),0)+1 FROM im_tenant
|
||||
ON DUPLICATE KEY UPDATE next_id=GREATEST(next_id,VALUES(next_id))`)
|
||||
return gerror.Wrap(err, "校准商户编号序列失败")
|
||||
}
|
||||
|
||||
func nextTenantID(tx gdb.TX) (int64, error) {
|
||||
value, err := tx.GetValue("SELECT next_id FROM " + imTenantIDSequenceTable + " WHERE sequence_name='tenant' FOR UPDATE")
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "锁定商户编号序列失败")
|
||||
}
|
||||
id := value.Int64()
|
||||
if id <= 0 {
|
||||
return 0, gerror.New("商户编号序列不正确")
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE "+imTenantIDSequenceTable+" SET next_id=? WHERE sequence_name='tenant'", id+1); err != nil {
|
||||
return 0, gerror.Wrap(err, "更新商户编号序列失败")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func tenantTablePrefix(tenantID int64) string {
|
||||
return fmt.Sprintf("im_%03d_", tenantID)
|
||||
}
|
||||
|
||||
func provisionTenantMessageTables(ctx context.Context, tenantID int64) error {
|
||||
if tenantID <= 0 {
|
||||
return gerror.New("商户ID不能为空")
|
||||
}
|
||||
db, err := imtenant.SharedDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := tenantTablePrefix(tenantID)
|
||||
baseTables := [][2]string{
|
||||
{prefix + "friendships", "im_friendships"},
|
||||
{prefix + "messages", "im_messages"},
|
||||
{prefix + "topics", "im_topics"},
|
||||
{prefix + "group_members", "im_group_members"},
|
||||
{prefix + "subscriptions", "im_subscriptions"},
|
||||
{prefix + "system_notifications", "im_system_notifications"},
|
||||
}
|
||||
for _, pair := range baseTables {
|
||||
if _, err = db.Exec(ctx, fmt.Sprintf("CREATE TABLE IF NOT EXISTS `%s` LIKE `%s`", pair[0], pair[1])); err != nil {
|
||||
return gerror.Wrapf(err, "创建商户业务表 %s 失败", pair[0])
|
||||
}
|
||||
if _, err = db.Exec(ctx, fmt.Sprintf("ALTER TABLE `%s` MODIFY tenant_id BIGINT NOT NULL DEFAULT %d COMMENT '商户ID'", pair[0], tenantID)); err != nil {
|
||||
return gerror.Wrapf(err, "设置商户业务表 %s 默认商户失败", pair[0])
|
||||
}
|
||||
}
|
||||
|
||||
first := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, offset := range []int{-1, 0, 1, 2} {
|
||||
start := first.AddDate(0, offset, 0)
|
||||
month := start.Format("200601")
|
||||
table := prefix + "group_messages_" + month
|
||||
if err = createTenantGroupMessageTable(ctx, db, table, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
status := "prepared"
|
||||
if offset == -1 {
|
||||
status = "sealing"
|
||||
} else if offset == 0 {
|
||||
status = "writable"
|
||||
}
|
||||
if _, err = db.Exec(ctx, `INSERT INTO im_group_message_shards
|
||||
(tenant_id,shard_month,table_name,shard_start,shard_end,status)
|
||||
VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE table_name=VALUES(table_name),
|
||||
shard_start=VALUES(shard_start),shard_end=VALUES(shard_end),
|
||||
status=IF(status IN ('sealed','archived'),status,VALUES(status))`,
|
||||
tenantID, month, table, start, start.AddDate(0, 1, 0), status); err != nil {
|
||||
return gerror.Wrapf(err, "登记商户群消息月表 %s 失败", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createTenantGroupMessageTable(ctx context.Context, db gdb.DB, table string, tenantID int64) error {
|
||||
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
tenant_id BIGINT NOT NULL DEFAULT %d, message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
|
||||
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
|
||||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
content JSON, content_nonce VARBINARY(12), key_version INT NOT NULL DEFAULT 0,
|
||||
head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
|
||||
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY(message_id), UNIQUE INDEX uk_group_seq(group_id, seqid),
|
||||
UNIQUE INDEX uk_topic_seq(topic, seqid), UNIQUE INDEX uk_sender_client(topic, from_user, client_msg_id),
|
||||
INDEX idx_group_status_seq(group_id, status, seqid), INDEX idx_topic_created(topic, created_at),
|
||||
INDEX idx_topic_from_seq(topic, from_user, seqid), INDEX idx_audit_created(audit_status, created_at),
|
||||
INDEX idx_expires(expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, table, tenantID)
|
||||
if _, err := db.Exec(ctx, ddl); err != nil {
|
||||
return gerror.Wrapf(err, "创建商户群消息月表 %s 失败", table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/hgorm/handler"
|
||||
"hotgo/internal/library/imworkbench"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func initializeTenantWorkbench(ctx context.Context, tenantID int64, tenantCode string) error {
|
||||
if tenantID <= 0 {
|
||||
return gerror.New("商户ID不能为空")
|
||||
}
|
||||
if tenantCode == "" {
|
||||
value, err := NewSysImTenant().Model(ctx, &handler.Option{FilterAuth: false}).
|
||||
Fields(dao.ImTenant.Columns().Code).
|
||||
WherePri(tenantID).
|
||||
Value()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "查询商户编码失败")
|
||||
}
|
||||
tenantCode = value.String()
|
||||
if tenantCode == "" {
|
||||
return gerror.New("商户不存在")
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
}
|
||||
if err := imworkbench.DefaultClient(ctx).PostForTenant(ctx, "/internal/admin/workbench/tenants/initialize", g.Map{
|
||||
"tenant_id": tenantID,
|
||||
"tenant_code": tenantCode,
|
||||
}, tenantID, tenantCode, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.TenantID != tenantID {
|
||||
return gerror.New("工作台初始化返回的商户ID不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -206,7 +206,12 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []
|
||||
values.Set("user_ids", strings.Join(userIds, ","))
|
||||
}
|
||||
if in.UserId != "" {
|
||||
values.Set("account_no", in.UserId)
|
||||
// 长数字按用户ID(u.id)精确匹配,短数字按后台展示编号(account_no)匹配
|
||||
if len(in.UserId) >= 12 {
|
||||
values.Set("user_id", in.UserId)
|
||||
} else {
|
||||
values.Set("account_no", in.UserId)
|
||||
}
|
||||
}
|
||||
if in.Nickname != "" {
|
||||
values.Set("nickname", in.Nickname)
|
||||
@@ -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
|
||||
@@ -807,7 +861,7 @@ func updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, user
|
||||
eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("user-status:%d:%d:%d:%s", scope.TenantId, userId, state, requestId)))
|
||||
eventKey := hex.EncodeToString(eventKeyBytes[:])
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
current, txErr := tx.GetValue("SELECT state FROM im_users WHERE id=? LIMIT 1", userId)
|
||||
current, txErr := tx.GetValue("SELECT state FROM im_users WHERE tenant_id=? AND id=? LIMIT 1", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -820,17 +874,17 @@ func updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, user
|
||||
if current.Int() == state {
|
||||
return nil
|
||||
}
|
||||
if _, txErr = tx.Exec("UPDATE im_users SET state=?, updatedat=? WHERE id=?", state, now, userId); txErr != nil {
|
||||
if _, txErr = tx.Exec("UPDATE im_users SET state=?, updatedat=? WHERE tenant_id=? AND id=?", state, now, scope.TenantId, userId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if _, txErr = tx.Exec(`INSERT INTO im_user_security
|
||||
(id,user_id,auth_version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
(tenant_id,id,user_id,auth_version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`,
|
||||
securityId, userId, 2, now, now); txErr != nil {
|
||||
scope.TenantId, securityId, userId, 2, now, now); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId)
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -838,9 +892,9 @@ func updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, user
|
||||
"user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "state": state, "auth_version": version.Int(),
|
||||
})
|
||||
_, txErr = tx.Exec(`INSERT INTO im_business_events_outbox
|
||||
(id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user",
|
||||
(tenant_id,id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, scope.TenantId, eventId, eventKey, "user",
|
||||
fmt.Sprintf("%d", userId), 0, "security.user_status_changed", string(payload), now, now)
|
||||
return txErr
|
||||
})
|
||||
@@ -866,7 +920,7 @@ func revokeIMUserSessions(ctx context.Context, db gdb.DB, scope imAdminScope, us
|
||||
eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d:%s", eventType, scope.TenantId, userId, requestId)))
|
||||
eventKey := hex.EncodeToString(eventKeyBytes[:])
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE id=? LIMIT 1)", userId)
|
||||
exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE tenant_id=? AND id=? LIMIT 1)", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -874,13 +928,13 @@ func revokeIMUserSessions(ctx context.Context, db gdb.DB, scope imAdminScope, us
|
||||
return gerror.New("IM用户不存在")
|
||||
}
|
||||
if _, txErr = tx.Exec(`INSERT INTO im_user_security
|
||||
(id,user_id,auth_version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
(tenant_id,id,user_id,auth_version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`,
|
||||
securityId, userId, 2, now, now); txErr != nil {
|
||||
scope.TenantId, securityId, userId, 2, now, now); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId)
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -888,9 +942,9 @@ func revokeIMUserSessions(ctx context.Context, db gdb.DB, scope imAdminScope, us
|
||||
"user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "auth_version": version.Int(),
|
||||
})
|
||||
_, txErr = tx.Exec(`INSERT INTO im_business_events_outbox
|
||||
(id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user",
|
||||
(tenant_id,id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, scope.TenantId, eventId, eventKey, "user",
|
||||
fmt.Sprintf("%d", userId), 0, eventType, string(payload), now, now)
|
||||
return txErr
|
||||
})
|
||||
@@ -923,14 +977,14 @@ func resetIMUserPassword(ctx context.Context, db gdb.DB, scope imAdminScope, use
|
||||
"user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId,
|
||||
})
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE id=? LIMIT 1)", userId)
|
||||
exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE tenant_id=? AND id=? LIMIT 1)", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if !exists.Bool() {
|
||||
return gerror.New("IM用户不存在")
|
||||
}
|
||||
result, txErr := tx.Exec("UPDATE im_auth SET secret=? WHERE userid=?", passwordHash, userId)
|
||||
result, txErr := tx.Exec("UPDATE im_auth SET secret=? WHERE tenant_id=? AND userid=?", passwordHash, scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -938,14 +992,14 @@ func resetIMUserPassword(ctx context.Context, db gdb.DB, scope imAdminScope, use
|
||||
return gerror.New("用户认证记录不存在")
|
||||
}
|
||||
if _, txErr = tx.Exec(`INSERT INTO im_user_security
|
||||
(id,user_id,auth_version,password_updated_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
(tenant_id,id,user_id,auth_version,password_updated_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE auth_version=auth_version+1,
|
||||
password_updated_at=VALUES(password_updated_at),updated_at=VALUES(updated_at)`,
|
||||
securityId, userId, 2, now, now, now); txErr != nil {
|
||||
scope.TenantId, securityId, userId, 2, now, now, now); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId)
|
||||
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?", scope.TenantId, userId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
@@ -954,9 +1008,9 @@ func resetIMUserPassword(ctx context.Context, db gdb.DB, scope imAdminScope, use
|
||||
eventPayload["auth_version"] = version.Int()
|
||||
payload, _ = json.Marshal(eventPayload)
|
||||
_, txErr = tx.Exec(`INSERT INTO im_business_events_outbox
|
||||
(id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user",
|
||||
(tenant_id,id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
|
||||
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, scope.TenantId, eventId, eventKey, "user",
|
||||
fmt.Sprintf("%d", userId), 0, "security.password_reset", string(payload), now, now)
|
||||
return txErr
|
||||
})
|
||||
|
||||
@@ -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).GetForTenant(ctx, "/internal/admin/workbench/apps", values, tenant.Id, tenant.Code, &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失败,请稍后重试!")
|
||||
}
|
||||
targetId = idValue.Int64()
|
||||
}
|
||||
var res sysin.ImWorkbenchAppModel
|
||||
if err = imworkbench.DefaultClient(ctx).PostForTenant(ctx, "/internal/admin/workbench/apps", data, tenant.Id, tenant.Code, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
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).PostForTenant(ctx, "/internal/admin/workbench/apps/disable", g.Map{"id": in.Id, "code": in.Code}, tenant.Id, tenant.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
|
||||
items := make([]g.Map, 0, len(in.Items))
|
||||
for _, item := range in.Items {
|
||||
items = append(items, g.Map{
|
||||
"code": item.Code,
|
||||
"sort_order": item.SortOrder,
|
||||
})
|
||||
}
|
||||
if err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
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
|
||||
}
|
||||
if err = imworkbench.DefaultClient(ctx).PostForTenant(ctx, "/internal/admin/workbench/apps/reorder", g.Map{"items": items}, tenant.Id, tenant.Code, 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,206 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/maoyuadmin"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
// 毛隅内容治理:概览、动态/评论审核、举报、操作日志。
|
||||
// 与养成内容治理同一套页面协议,业务数据经 maoyu-server 内部接口处理,Manager 不直连毛隅业务库。
|
||||
|
||||
func init() { service.RegisterSysMaoyuContent(NewSysMaoyuContent()) }
|
||||
|
||||
type sSysMaoyuContent struct{}
|
||||
|
||||
func NewSysMaoyuContent() *sSysMaoyuContent { return &sSysMaoyuContent{} }
|
||||
|
||||
// maoyuAdminScope 从管理员登录态构造毛隅内部接口调用范围。
|
||||
func maoyuAdminScope(ctx context.Context) (maoyuadmin.Scope, error) {
|
||||
tenant, err := resolveImAdminTenant(ctx, cultivateTenantID(ctx))
|
||||
if err != nil {
|
||||
return maoyuadmin.Scope{}, err
|
||||
}
|
||||
operatorID := contexts.GetUserId(ctx)
|
||||
if operatorID <= 0 {
|
||||
return maoyuadmin.Scope{}, gerror.New("当前管理员身份无效")
|
||||
}
|
||||
return maoyuadmin.Scope{
|
||||
TenantID: tenant.Id, TenantCode: tenant.Code, OperatorID: operatorID,
|
||||
RoleKey: contexts.GetRoleKey(ctx), RequestID: gctx.CtxId(ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) client(ctx context.Context) *maoyuadmin.Client {
|
||||
return maoyuadmin.DefaultClient(ctx)
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) Dashboard(ctx context.Context, _ *sysin.MaoyuDashboardInp) (*sysin.MaoyuDashboardModel, error) {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.MaoyuDashboardModel)
|
||||
if err = s.client(ctx).Get(ctx, scope, "/dashboard", nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) 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
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "object_type", in.ObjectType)
|
||||
cultivateSetQuery(values, "author_id", in.AuthorId)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "keyword", strings.TrimSpace(in.Keyword))
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.CultivateContentListModel]
|
||||
if err = s.client(ctx).Get(ctx, scope, "/content-reviews", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateContentListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) View(ctx context.Context, in *sysin.CultivateContentViewInp) (*sysin.CultivateContentViewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateContentViewModel)
|
||||
path := "/content-reviews/" + url.PathEscape(in.ObjectType) + "/" + url.PathEscape(in.Id)
|
||||
if err = s.client(ctx).Get(ctx, scope, path, nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/content-reviews/" + url.PathEscape(in.ObjectType) + "/" + url.PathEscape(in.Id) + "/actions"
|
||||
return s.client(ctx).Post(ctx, scope, path, map[string]any{"action": in.Action, "reason": strings.TrimSpace(in.Reason)}, nil)
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) BatchReview(ctx context.Context, in *sysin.CultivateContentBatchReviewInp) (*sysin.CultivateContentBatchReviewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateContentBatchReviewModel)
|
||||
err = s.client(ctx).Post(ctx, scope, "/content-reviews/batch-actions", map[string]any{
|
||||
"object_type": in.ObjectType, "ids": in.Ids, "action": in.Action, "reason": strings.TrimSpace(in.Reason),
|
||||
}, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) 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
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "object_type", in.ObjectType)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "reporter_id", in.ReporterId)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.CultivateReportListModel]
|
||||
if err = s.client(ctx).Get(ctx, scope, "/reports", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateReportListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) ReportView(ctx context.Context, in *sysin.CultivateReportViewInp) (*sysin.CultivateReportViewModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.CultivateReportViewModel)
|
||||
if err = s.client(ctx).Get(ctx, scope, "/reports/"+url.PathEscape(in.Id), nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client(ctx).Post(ctx, scope, "/reports/"+url.PathEscape(in.Id)+"/actions", map[string]any{
|
||||
"action": in.Action, "handle_result": in.HandleResult, "content_action": in.ContentAction,
|
||||
"user_action": in.UserAction, "reason": in.Reason,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuContent) LogList(ctx context.Context, in *sysin.CultivateOperationLogListInp) (list []*sysin.CultivateOperationLogListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "operator_id", in.OperatorId)
|
||||
cultivateSetQuery(values, "operation_type", in.OperationType)
|
||||
cultivateSetQuery(values, "target_type", in.TargetType)
|
||||
cultivateSetQuery(values, "target_id", in.TargetId)
|
||||
cultivateSetQuery(values, "request_id", in.RequestId)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.CultivateOperationLogListModel]
|
||||
if err = s.client(ctx).Get(ctx, scope, "/operation-logs", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.CultivateOperationLogListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"hotgo/internal/library/maoyuadmin"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
// 毛隅萌宠管理:互动动作配置(新增/编辑/启用停用)、宠物档案(只读)、场景生成任务(只读)、互动通知(只读)。
|
||||
|
||||
func init() { service.RegisterSysMaoyuPet(NewSysMaoyuPet()) }
|
||||
|
||||
type sSysMaoyuPet struct{}
|
||||
|
||||
func NewSysMaoyuPet() *sSysMaoyuPet { return &sSysMaoyuPet{} }
|
||||
|
||||
func (s *sSysMaoyuPet) ActionConfigList(ctx context.Context, in *sysin.MaoyuActionConfigListInp) (list []*sysin.MaoyuActionConfigListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "keyword", in.Keyword)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "version", in.Version)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.MaoyuActionConfigListModel]
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/action-configs", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.MaoyuActionConfigListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) ActionConfigView(ctx context.Context, in *sysin.MaoyuActionConfigViewInp) (*sysin.MaoyuActionConfigViewModel, error) {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.MaoyuActionConfigViewModel)
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/action-configs/"+url.PathEscape(in.Id), nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) ActionConfigEdit(ctx context.Context, in *sysin.MaoyuActionConfigEditInp) error {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"action_name": in.ActionName, "daily_limit": in.DailyLimit, "cooldown_seconds": in.CooldownSeconds,
|
||||
"mutex_group": in.MutexGroup, "asset_key": in.AssetKey, "effect_config": in.EffectConfig, "sort_no": in.SortNo,
|
||||
}
|
||||
if id := strings.TrimSpace(in.Id); id != "" {
|
||||
return maoyuadmin.DefaultClient(ctx).Put(ctx, scope, "/action-configs/"+url.PathEscape(id), body, nil)
|
||||
}
|
||||
body["action_code"] = in.ActionCode
|
||||
return maoyuadmin.DefaultClient(ctx).Post(ctx, scope, "/action-configs", body, nil)
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) ActionConfigStatus(ctx context.Context, in *sysin.MaoyuActionConfigStatusInp) error {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return maoyuadmin.DefaultClient(ctx).Post(ctx, scope, "/action-configs/"+url.PathEscape(in.Id)+"/status", map[string]any{
|
||||
"reason": strings.TrimSpace(in.Reason),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) PetList(ctx context.Context, in *sysin.MaoyuPetListInp) (list []*sysin.MaoyuPetListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "user_id", in.UserId)
|
||||
cultivateSetQuery(values, "pet_type", in.PetType)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "keyword", in.Keyword)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.MaoyuPetListModel]
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/pets", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.MaoyuPetListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) PetView(ctx context.Context, in *sysin.MaoyuPetViewInp) (*sysin.MaoyuPetViewModel, error) {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.MaoyuPetViewModel)
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/pets/"+url.PathEscape(in.Id), nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) JobList(ctx context.Context, in *sysin.MaoyuJobListInp) (list []*sysin.MaoyuJobListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "pet_id", in.PetId)
|
||||
cultivateSetQuery(values, "user_id", in.UserId)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "error_code", in.ErrorCode)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.MaoyuJobListModel]
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/generation-jobs", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.MaoyuJobListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) JobView(ctx context.Context, in *sysin.MaoyuJobViewInp) (*sysin.MaoyuJobViewModel, error) {
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(sysin.MaoyuJobViewModel)
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/generation-jobs/"+url.PathEscape(in.Id), nil, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysMaoyuPet) NoticeList(ctx context.Context, in *sysin.MaoyuNoticeListInp) (list []*sysin.MaoyuNoticeListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := maoyuAdminScope(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
normalizeCultivatePage(&in.PageReq)
|
||||
values := cultivatePageValues(in.Page, in.PerPage)
|
||||
cultivateSetQuery(values, "recipient_id", in.RecipientId)
|
||||
cultivateSetQuery(values, "actor_id", in.ActorId)
|
||||
cultivateSetQuery(values, "event_type", in.EventType)
|
||||
cultivateSetQuery(values, "status", in.Status)
|
||||
cultivateSetQuery(values, "post_id", in.PostId)
|
||||
setTimeRange(values, in.CreatedAt)
|
||||
var page maoyuadmin.Page[*sysin.MaoyuNoticeListModel]
|
||||
if err = maoyuadmin.DefaultClient(ctx).Get(ctx, scope, "/interaction-notices", values, &page); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page.Items == nil {
|
||||
page.Items = []*sysin.MaoyuNoticeListModel{}
|
||||
}
|
||||
return page.Items, page.Total, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user