Compare commits

..
88 Commits
Author SHA1 Message Date
Your Name ba837f4cae fix: use production logging profile
Deploy im-manager production / deploy (push) Successful in 4m33s
2026-10-19 01:58:31 +08:00
Your Name 0553424ef9 ci: deploy pro with docker compose
Deploy im-manager production / deploy (push) Successful in 10m56s
2026-10-18 20:11:07 +08:00
Austin 64037863ef feat: 添加毛隅管理模块的后台接口与菜单权限 2026-10-15 18:20:26 +08:00
Austin db2eb62277 fix: 删除过时的im_filemsglinks_msgid_bigint.sql文件
该文件是用于修复msgid字段类型从INT改为BIGINT的遗留SQL脚本,在完成数据库字段变更后已不再需要,删除后可避免后续误用该过时脚本。
2026-10-13 17:07:30 +08:00
Austin a3527a6d22 fix: 将 im_filemsglinks.msgid 字段类型从 INT 修改为 BIGINT
该字段原类型为 INT,最大值仅为 2147483647,而消息 ID 是雪花算法生成的 int64 类型值,插入时会超出范围导致失败。失败的 Kafka 消息持久化消费者会无限重试,阻塞分区及后续消息的持久化。此修改适用于 2026-10-13 之前部署的所有环境,全局非租户分片表仅需执行一次。
2026-10-13 15:39:49 +08:00
Austin 8d6618dac1 feat: 新增毛隅手记服务内部管理接口客户端及配置
新增 maoyuAdminApi 配置段到模板和示例配置,实现 maoyuadmin 客户端,包含请求签名、响应解析等功能,配套单元测试
2026-10-12 17:40:59 +08:00
Austin 2c5995073f feat: 新增禾圃集应用版本更新管理功能
新增禾圃集应用(appCode=garden)的版本策略管理功能,包括后端接口、前端API、页面路由、数据库迁移及相关常量配置,支持对不同平台和渠道的版本策略进行增删改查操作,与现有讯工捷使(appCode=im)版本管理功能保持一致的架构和交互逻辑。
2026-10-12 15:31:11 +08:00
Austin 315d97895b docs(cultivate/flowers): 更新阶段规则相关文本描述
修正了非 bloom 阶段规则的校验注释和错误提示文本,同时更新了编辑页的输入标签和占位符文本,使表述更准确对应后端校验逻辑。
2026-10-10 11:57:03 +08:00
Austin 3834af70b5 style(flowers): update modal selector to fix style priority issue
修改弹窗样式选择器,提升优先级以覆盖 naive-ui 运行时注入的固定宽度样式,解决 Teleport 弹窗的样式覆盖问题。
2026-10-10 10:26:02 +08:00
Austin 667e62e97e feat(cultivate): 添加花朵阶段中文标题并优化编辑页展示
新增阶段中文名映射及转换函数,用于显示更友好的阶段名称
在编辑页中应用阶段标题,同时调整下一阶段输入框为只读展示
优化弹窗宽度样式,适配不同屏幕尺寸
2026-10-10 10:08:22 +08:00
Austin 55ae8b2acd feat(cultivate-flower): 新增等待时长字段并更新阶段规则校验逻辑 2026-10-08 12:47:17 +08:00
Austin 5a7d8407bc refactor(web): 注释通知相关代码并添加临时注释说明
注释右上角通知铃铛及实时推送相关代码,添加说明后续恢复时取消注释
2026-09-30 15:37:33 +08:00
richjohnrambo 837a03a87e fix: cap mysql connection pool 2026-09-27 18:36:00 +08:00
richjohnrambo 0edbc63bc1 fix: keep tenant option list request 2026-09-25 23:31:33 +08:00
richjohnrambo 9e01f95a61 fix: allow bound tenant option loading 2026-09-25 23:27:11 +08:00
Austin fce8dbbe32 fix: backfill tenant via useForm channel and skip empty-tenant watch on search reset
- imSearchReset now receives setFieldsValue from useForm() instead of the
  template ref instance method, which was unreliable for backfilling tenantId
- imUser/imSensitiveWord watches skip tenantId<=0 so reset no longer fires
  policy-group/group requests that get rejected with empty tenant
- loadImTenantOptions wraps platform-branch TenantList in try/catch so a
  failed request no longer aborts page initialization
2026-09-24 20:32:32 +08:00
Austin 43f3581b1c fix: restore tenant filter on search reset in IM pages
BasicForm resetFields clears tenantId along with other fields, so the
auto-query after reset hit the empty-list guard (or was rejected by the
backend). Add a shared imSearchReset handler in imTenantOptions.ts that
refills the tenant select with its first option (bound users keep their
own tenant) before reloading, and wire it into the 9 IM list pages whose
reset did not already refill the tenant condition.
2026-09-24 17:02:08 +08:00
richjohnrambo 9924c1e522 fix: restore lockscreen timeout to 72 hours 2026-09-24 01:31:41 +08:00
richjohnrambo e87ac92cd1 Revert "refactor: 移除锁屏功能相关代码"
This reverts commit e002e45f82.
2026-09-24 01:30:49 +08:00
richjohnrambo e24a04a920 fix: prevent admin bootstrap merge helper race 2026-09-24 01:20:59 +08:00
richjohnrambo 32017a2fbf fix: avoid stale admin asset cache 2026-09-24 01:01:57 +08:00
richjohnrambo 369898bd7c feat: integrate cultivate admin internal APIs 2026-09-24 00:02:27 +08:00
Austin e002e45f82 refactor: 移除锁屏功能相关代码 2026-09-23 17:39:18 +08:00
richjohnrambo abadcceb19 feat: initialize shared tenant workbench data 2026-09-23 17:04:30 +08:00
Austin 5a07f63716 feat: 添加实时锁定状态控制解锁按钮显示
添加实时锁定状态字段到登录失败日志模型
并修改解锁按钮仅在账户当前仍锁定时显示
2026-09-23 13:18:35 +08:00
richjohnrambo 6c338626d6 fix: validate im registration platform role 2026-09-23 02:51:58 +08:00
richjohnrambo 22adc13acf feat: shard tenant topic and membership tables 2026-09-23 02:14:13 +08:00
richjohnrambo 432e7ac51a fix: keep only realname review policy on tenant 2026-09-23 02:06:52 +08:00
richjohnrambo dd12dbfe07 fix: cache admin permissions in redis 2026-09-23 00:30:40 +08:00
richjohnrambo 4fbc896d75 fix: sync tenant entry menu casbin rules 2026-09-23 00:07:20 +08:00
richjohnrambo 603e89e204 fix: query tenant entry tables without prefix 2026-09-22 23:52:08 +08:00
richjohnrambo abd5eb162a feat: add tenant entry application pages 2026-09-22 23:17:55 +08:00
Austin 935bfe17a0 fix(admin/member): 允许校验租户时包含初始化状态
修改租户校验逻辑,将允许的租户状态从仅活跃态扩展至包含初始化态,确保新建商户创建管理员操作正常完成
2026-09-22 19:38:54 +08:00
Austin 671ac2e1f7 fix(security-policy): 修复策略保存时租户行更新时间与事件投递阻塞问题
调整策略保存时的租户表更新逻辑,显式赋值updated_at避免自动填充干扰
修改策略变更事件投递逻辑,失败时仅告警审计不阻塞主流程
新增租户无策略时加载模板初始化配置的逻辑,提升默认策略灵活性
2026-09-22 19:08:53 +08:00
Austin dc3c45cd53 feat: 将安全策略模板化,新建商户时自动复制初始策略 2026-09-22 18:32:08 +08:00
richjohnrambo 9a9e30f94e feat: add tenant list id sorting 2026-09-22 17:32:08 +08:00
richjohnrambo 85b564e2ac refactor: merge tenant configuration into tenant registry 2026-09-22 16:47:40 +08:00
richjohnrambo 851eca97ed feat: support shared tenant database sharding 2026-09-22 15:21:34 +08:00
Austin 0596599db9 feat: add tenant bound restriction for IM dashboard and management pages
1. extract isTenantBound utility from user store logic
2. add disabled state to tenant select components across all IM pages
3. update policy tenant options logic to hide global option for bound tenants
4. add backend scope validation for IM admin operations
2026-09-22 11:34:40 +08:00
Austin 40ac52923e feat(imTenant): 新增商户管理员账号查看与密码修改功能
1.  新增编辑页管理员账号展示与密码修改字段,仅编辑态可见
2.  新增商户管理员密码重置逻辑,完善编辑场景密码校验
3.  补充超管可见控制:业务库、连接串、管理员信息非超管脱敏
4.  修复schema未使用响应式值的问题
2026-09-21 18:44:37 +08:00
Austin c9673ca871 feat: 新增商户管理员账号自动创建功能,优化租户权限控制
1.  后端实现租户创建时自动绑定创建管理员账号,支持超管/普通管理员权限区分
2.  前端适配超管权限控制业务库名、连接串等敏感字段展示与编辑
3.  优化租户数据库回退逻辑,未配置独立库时自动回落默认库
4.  调整租户列表与详情页的权限数据脱敏逻辑
2026-09-21 17:50:38 +08:00
Austin 197c6c86f4 fix: 修复多页面租户未就绪时表格提前请求问题,优化登录审计相关文案与列宽
1. 为imSensitiveWord、imUserPolicyGroup、imSecurityPolicy页面添加租户ID校验,避免表格挂载时因租户默认值未就绪发起无效请求
2. 更新imLoginFailureLog、imUserLoginHistory的搜索占位符与账号列宽
3. 修正后端登录审计相关结构体的字段注释描述
2026-09-21 16:25:12 +08:00
Austin a2f328f97e style(im/registrationInvite): 调整绑定用户提示的插槽封装与样式
将绑定用户未验证提示调整为插槽形式,并移除冗余的样式配置
2026-09-21 14:57:49 +08:00
Austin c4ab970fb7 fix(im/registrationInvite): add input verification for bound user search
1. add computed state to check if bind user input is unverified
2. disable confirm button and show warning tip when input is unverified
3. track last searched keyword to validate user input
4. add submission guard to prevent unvalidated submissions
2026-09-21 14:45:10 +08:00
Austin 8fad32e396 docs: update IM login lock error prompt copy and description
修正登录锁定错误提示文案,补充完整的时长格式化规则,更新示例文案匹配新规则
2026-09-21 13:57:53 +08:00
Austin 6f331a07fc style(imTenant): adjust action column width to 320px
widen the operation column to fit longer action buttons properly
2026-09-21 13:27:52 +08:00
Austin b4174a6412 feat: 新增IM联系我们字典配置,优化用户搜索与邀请绑定功能
1. 优化IM用户列表搜索逻辑,支持长数字用户ID精确匹配
2. 修复前端雪花ID精度丢失问题,改用字符串传输用户ID
3. 新增邀请码批量绑定用户功能,支持搜索绑定指定用户
4. 新增字典配置接口,新增联系我们字典SQL脚本
5. 完善字典数据变更事件推送,优化弹窗关闭与页面刷新逻辑
2026-09-21 13:11:08 +08:00
Austin 0381984939 Merge branch 'dev' of https://git.118im.com/im_team/im_manager into dev 2026-09-21 09:28:09 +08:00
Austin 6348ace38e feat(im security): add login failure lockout configuration feature
1. add APIs for login lockout config get and save
2. add backend service and controller for login lockout config
3. refactor frontend to separate login lockout config from main policy
4. update default login lockout policy values
5. add permission sql and docking documentation
2026-09-21 09:28:03 +08:00
richjohnrambo e8dd1eaa7d feat: 增加商户消息加密密钥管理 2026-09-20 23:43:33 +08:00
richjohnrambo 42cfe232fe fix: IM设置菜单更名为平台设置 2026-09-20 22:43:57 +08:00
richjohnrambo 4ae69706a5 fix: 区分IM系统设置菜单标题 2026-09-20 22:31:54 +08:00
richjohnrambo 3ef328a5a3 fix: 隐藏内置超管时保留角色树 2026-09-20 22:03:51 +08:00
richjohnrambo 2b93d27a4e fix: 角色列表隐藏内置超管 2026-09-20 21:53:38 +08:00
richjohnrambo 97990785cb fix: 平台超管权限走角色配置 2026-09-20 21:50:43 +08:00
richjohnrambo b68c048383 fix: 平台超管可管理后台角色 2026-09-20 21:48:10 +08:00
richjohnrambo f154b9e3c3 fix: 按角色规范后台用户商户绑定 2026-09-20 21:03:54 +08:00
richjohnrambo e2ca0607af fix: 平台超管拥有全部商户数据范围 2026-09-20 20:05:08 +08:00
richjohnrambo 6afeb65b91 feat: add tenant realname review policy 2026-09-20 18:31:46 +08:00
richjohnrambo e5ab79c40d fix: 拆分商户新增和编辑权限 2026-09-20 17:08:50 +08:00
Austin 8675fe333d feat(imLoginFailureLog): 用户登录密码错误次数锁定与解锁能力(管理端)
- 安全策略登录限频由 JSON 文本改为结构化表单(次数/锁定分钟/窗口分钟),编辑校验同步扩展
- 失败日志新增解锁入口:/imLoginFailureLog/unlock 经 internal 通道调用 im-server 释放锁定
- 失败日志模型补充 accountHash 字段,解锁按钮按 risk_action=account_locked 展示
- 租户业务库初始化模板补充 im_login_lockouts 等表;新增解锁按钮菜单权限 SQL
2026-09-20 17:04:19 +08:00
richjohnrambo fd4ff85191 fix: 限制IM租户列表为当前租户 2026-09-20 16:53:38 +08:00
richjohnrambo 6bf364ec0b fix: 修复养成管理接口参数过滤 2026-09-20 16:53:38 +08:00
Austin 47ed097923 refactor(imWorkOrder): 优化工单分类和目标类型的展示逻辑
将工单分类和目标类型的格式化逻辑抽离到model文件中,统一处理字典映射展示,替换原有的硬编码空值占位,同时修复列表页和详情页的展示不一致问题
2026-09-20 10:53:41 +08:00
Austin e5cfc80916 chore(imFile): 临时隐藏用途相关功能
注释掉页面、表单和列表中的用途相关代码,后续需要时可恢复
2026-09-20 10:44:27 +08:00
richjohnrambo c7265252d8 feat: 完善养成管理后台功能 2026-09-19 17:07:47 +08:00
richjohnrambo f6766d3dbd feat: 新增养成管理后台 2026-09-19 14:36:30 +08:00
richjohnrambo 7c8b4d9b7c feat: add friend add and curfew controls 2026-09-18 10:55:33 +08:00
richjohnrambo 348cbcaf96 fix: allow multiple config api service names 2026-09-17 22:55:21 +08:00
richjohnrambo 2a35cb4358 fix: update uid encryption key seed 2026-09-17 16:59:47 +08:00
richjohnrambo be6c5b525c fix: seed uid encryption config 2026-09-17 15:56:23 +08:00
richjohnrambo 9ed8aaf142 refactor: reuse global aliyun moderation config 2026-09-17 00:25:04 +08:00
richjohnrambo 4ed54ca392 test: initialize admin member service in scope tests 2026-09-16 23:45:21 +08:00
richjohnrambo 5173bc0aad fix: return default moderation policy when unpublished 2026-09-16 23:30:10 +08:00
richjohnrambo fec2a43b9c feat: add tenant content moderation controls 2026-09-16 22:21:42 +08:00
Austin 32491e9cdd fix: repair healthz mysql check and im tenant creation
- healthz: Scan(new(int)) is not supported by GoFrame, use .One() so
  the mysql check no longer reports a false failure
- im_tenant: set updated_by on insert for both im_tenant and
  im_tenant_config, otherwise new tenant creation fails with
  "Field 'updated_by' doesn't have a default value"
- gitignore: exclude .trae/ local debugging artifacts
2026-09-16 18:45:37 +08:00
richjohnrambo fe5c7af7e2 fix: show bound tenant in im menus 2026-09-16 16:54:12 +08:00
richjohnrambo 156e8b9c9f fix: align workbench default secret 2026-09-16 16:05:39 +08:00
richjohnrambo f8aaae42ff fix: use workbench service dns 2026-09-16 15:41:32 +08:00
richjohnrambo 82379b8823 fix: configure workbench service secret fallback 2026-09-16 15:21:28 +08:00
richjohnrambo 3e40554229 fix: fallback workbench admin secret 2026-09-16 14:28:42 +08:00
richjohnrambo e159f9a88e feat: proxy workbench admin through service 2026-09-16 14:03:58 +08:00
richjohnrambo c258e0473b fix: resolve bound im tenant for company admins 2026-09-16 13:19:55 +08:00
richjohnrambo 5dbbf39cce chore: update test environment defaults 2026-09-15 11:59:14 +08:00
richjohnrambo 50a2781b23 fix: avoid gf cli install in docker build 2026-09-14 14:27:23 +08:00
richjohnrambo a6f7ac41b0 fix: remove duplicate IM frontend API exports 2026-09-14 14:13:39 +08:00
richjohnrambo c1a6a41575 fix: update admin test environment config 2026-09-14 14:06:41 +08:00
Your NameandClaude Sonnet 5 f44e8adb72 ci: bring k3s deploy workflow over from main
This commit only ever landed on main (from when main was still the
default branch); dev's copy was still the stale blue-green version.
Bringing main's file over as-is before making dev the default branch,
so neither branch ends up missing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:28:55 +08:00
252 changed files with 14051 additions and 2933 deletions
Vendored
BIN
View File
Binary file not shown.
+56 -30
View File
@@ -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"
+3
View File
@@ -163,3 +163,6 @@ dist
.yarn/install-state.gz
.pnp.*
# Trae local debugging artifacts
.trae/
+27 -14
View File
@@ -21,11 +21,16 @@ COPY server/ .
RUN rm -rf resource/public/admin && mkdir -p resource/public/admin
COPY --from=web-build /web/dist/ ./resource/public/admin/
# main.go does `import _ "hotgo/internal/packed"`, a file normally generated by
# `gf build` (see server/hack/config.yaml: packSrc/packDst) and gitignored —
# generate it explicitly with the same gf CLI version pinned in go.mod.
RUN go install github.com/gogf/gf/cmd/gf/v2@v2.10.0
RUN gf pack resource internal/packed/packed.go
# main.go imports internal/packed. Generate the packed resource file directly
# through GoFrame's library API instead of installing the full gf CLI in CI.
RUN printf '%s\n' \
'package main' \
'import "github.com/gogf/gf/v2/os/gres"' \
'func main() {' \
' if err := gres.PackToGoFileWithOption("resource", "internal/packed/packed.go", "packed", gres.Option{}); err != nil { panic(err) }' \
'}' > /tmp/pack.go \
&& go run /tmp/pack.go \
&& rm /tmp/pack.go
RUN CGO_ENABLED=0 go build -o /out/hotgo main.go
@@ -37,19 +42,27 @@ RUN apk add --no-cache ca-certificates gettext bash tzdata
WORKDIR /app
ENV QUEUE_DRIVER=kafka
ENV KAFKA_ADDRESS=64.118.137.182:9094
ENV KAFKA_ADDRESS=kafka.118im.com:30835
ENV KAFKA_VERSION=2.4.0
ENV KAFKA_USERNAME=kafka-external
ENV KAFKA_PASSWORD=gr58tNnlZOVGW3AmMbL2
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV IM_ADMIN_API_BASE_URL=http://im-server:6060
ENV KAFKA_USERNAME=
ENV KAFKA_PASSWORD=
ENV KAFKA_SASL_MECHANISM=
ENV MYSQL_DSN=
ENV REDIS_ADDR=redis.118im.com:30637
ENV REDIS_DB=2
ENV REDIS_PASSWORD=
ENV TOKEN_SECRET_KEY=hotgo123
ENV IM_ADMIN_API_BASE_URL=https://api.118im.com
ENV IM_ADMIN_API_SERVICE_ID=hotgo-admin
ENV IM_ADMIN_API_SECRET=8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b
ENV IM_INTERNAL_API_BASE_URL=http://im-server:6060
ENV IM_ADMIN_API_SECRET=
ENV IM_INTERNAL_API_BASE_URL=https://api.118im.com
ENV IM_INTERNAL_API_SERVICE_NAME=hotgo-admin
ENV IM_INTERNAL_API_SECRET=8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b
ENV IM_INTERNAL_API_SECRET=
ENV IM_WORKBENCH_API_BASE_URL=http://im-workbench.workbench-system.svc.cluster.local:6061
ENV IM_WORKBENCH_API_SERVICE_NAME=hotgo-admin
ENV IM_WORKBENCH_API_SECRET=hotgo-workbench-internal
ENV IM_CONFIG_API_SERVICE_NAME=im-server
ENV IM_CONFIG_API_SECRET=b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097
ENV IM_CONFIG_API_SECRET=
COPY --from=server-build /src/manifest ./manifest
COPY --from=server-build /src/hack ./hack
@@ -947,6 +947,22 @@ CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
KEY `idx_im_login_failure_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_login_lockouts` (
`id` bigint NOT NULL,
`user_id` bigint DEFAULT NULL,
`account_hash` char(64) NOT NULL,
`failed_attempts` int NOT NULL DEFAULT '0',
`window_started_at` datetime(3) DEFAULT NULL,
`locked_until` datetime(3) DEFAULT NULL,
`last_attempt_at` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_login_lockouts_account` (`account_hash`),
KEY `idx_im_login_lockouts_user` (`user_id`),
KEY `idx_im_login_lockouts_locked` (`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`id` bigint NOT NULL AUTO_INCREMENT,
`enabled` tinyint NOT NULL DEFAULT '1',
@@ -0,0 +1,30 @@
-- 商户级内容审核开关。
-- 可重复执行;新商户默认关闭,现有 default 商户保持当前审核行为。
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'aliyun_moderation_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `aliyun_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用阿里云内容审核'' AFTER `max_group_members`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'local_moderation_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `local_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用本地内容审核'' AFTER `aliyun_moderation_enabled`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
UPDATE `im_tenant_config` c
JOIN `im_tenant` t ON t.`id` = c.`tenant_id`
SET c.`aliyun_moderation_enabled` = 1,
c.`local_moderation_enabled` = 1
WHERE t.`code` = 'default';
@@ -0,0 +1,137 @@
-- 消息加密密钥管理。
-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。
SET NAMES utf8mb4;
SET @now := NOW();
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config'
AND COLUMN_NAME = 'message_encryption_enabled') = 0,
'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL COMMENT '商户ID',
`key_version` int unsigned NOT NULL COMMENT '密钥版本',
`algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法',
`encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK',
`dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce',
`status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled',
`activated_at` datetime DEFAULT NULL,
`retired_at` datetime DEFAULT NULL,
`remark` varchar(255) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` bigint NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`updated_by` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`),
KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本';
INSERT INTO `hg_sys_dict_type`
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999,
'仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret'
);
INSERT INTO `hg_sys_dict_data`
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret',
'', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `hg_sys_dict_data`
WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01'
);
SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1);
SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1);
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index',
'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index',
1, 'imManager', 0, 0, '', 0, 0, 0, 3,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80,
'商户消息加密开关及密钥版本管理', 1, @now, @now
WHERE @settingsId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex');
SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1);
UPDATE `hg_admin_menu`
SET `pid` = @settingsId,
`title` = '消息密钥管理',
`path` = 'message-encryption/index',
`icon` = 'KeyOutlined',
`permissions` = '/imMessageEncryption/list',
`component` = '/imMessageEncryption/index',
`active_menu` = 'imManager',
`level` = 3,
`tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '),
`sort` = 80,
`hidden` = 0,
`status` = 1,
`updated_at` = @now
WHERE `id` = @keyMenuId;
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '',
'/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize');
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '',
'/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate');
INSERT INTO `hg_admin_menu`
(`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`,
`permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`,
`frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`,
`status`, `created_at`, `updated_at`)
SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '',
'/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4,
CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now
WHERE @keyMenuId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch');
UPDATE `hg_admin_menu`
SET `level` = 4,
`tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '),
`updated_at` = @now
WHERE `pid` = @keyMenuId AND `type` = 3;
INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`)
SELECT 1, `id` FROM `hg_admin_menu`
WHERE `name` IN (
'imManager',
'imSystemSettings',
'imMessageEncryptionIndex',
'imMessageEncryptionInitialize',
'imMessageEncryptionRotate',
'imMessageEncryptionSwitch'
);
@@ -0,0 +1,33 @@
-- 阿里云文本审核统一复用 hg_sys_config.im_third_party 中的公共凭证、地域和端点。
-- 可重复执行;清理 im_external_config 中历史重复保存的密钥和连接参数。
INSERT INTO `im_external_config`
(`tenant_id`, `service`, `provider`, `endpoint`, `enabled`, `status`, `config_json`, `secret_json`, `created_by`, `updated_by`)
SELECT
t.`id`,
'moderation',
'aliyun_green',
'',
1,
'unconfigured',
JSON_OBJECT(
'nickname_service', 'nickname_detection_pro',
'chat_service', 'chat_detection_pro',
'timeout_ms', 1500,
'failure_mode', 'allow'
),
JSON_OBJECT(),
0,
0
FROM `im_tenant` t
WHERE t.`code` = 'default'
ON DUPLICATE KEY UPDATE
`provider` = VALUES(`provider`),
`endpoint` = VALUES(`endpoint`),
`enabled` = VALUES(`enabled`),
`status` = VALUES(`status`),
`config_json` = VALUES(`config_json`),
`secret_json` = VALUES(`secret_json`),
`checked_at` = NULL,
`check_result` = '',
`updated_at` = CURRENT_TIMESTAMP;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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');
+19
View File
@@ -0,0 +1,19 @@
-- ----------------------------------------------------------------
-- IM「联系我们」配置:字典管理新增 im_contact_us 字典类型及默认字典数据
-- 用途:移动端通过 im-server 接口 /v1/im/contact-us 读取展示客服邮箱
-- 依赖:无需前置 SQL
-- 幂等:重复执行不产生重复行
-- 说明:邮箱的最终值请在管理后台「系统管理 → 字典管理 → 联系我们」中维护
-- ----------------------------------------------------------------
USE hotgo;
SET @now := NOW();
-- 字典类型:联系我们(挂载在系统枚举 sys 下)
INSERT INTO `hg_sys_dict_type` (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT (SELECT `id` FROM `hg_sys_dict_type` WHERE `type` = 'sys' LIMIT 1), '联系我们', 'im_contact_us', 0, '移动端联系我们配置', 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_contact_us');
-- 字典数据:客服邮箱
INSERT INTO `hg_sys_dict_data` (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '客服邮箱', 'support@example.com', 'string', 'im_contact_us', '', 1, 10, '移动端展示的客服邮箱', 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_sys_dict_data` WHERE `type` = 'im_contact_us' AND `label` = '客服邮箱');
@@ -0,0 +1,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{}
+157 -3
View File
@@ -1,6 +1,10 @@
package imconfiginternal
import "github.com/gogf/gf/v2/frame/g"
import (
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type TenantReq struct {
g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"`
@@ -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"`
-80
View File
@@ -263,83 +263,3 @@ type AdminLogListRes struct {
form.PageRes
List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"`
}
// ProfileUpdateReq 通过Kafka修改IM群资料
type ProfileUpdateReq struct {
g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群资料"`
sysin.ImGroupProfileUpdateInp
}
type ProfileUpdateRes struct {
*sysin.ImGroupCommandModel
}
// GovernanceUpdateReq 通过Kafka修改IM群治理配置
type GovernanceUpdateReq struct {
g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群治理配置"`
sysin.ImGroupGovernanceUpdateInp
}
type GovernanceUpdateRes struct {
*sysin.ImGroupCommandModel
}
// AdminSetReq 通过Kafka设置IM群管理员
type AdminSetReq struct {
g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"Kafka设置IM群管理员"`
sysin.ImGroupAdminSetInp
}
type AdminSetRes struct {
*sysin.ImGroupCommandModel
}
// AdminRemoveReq 通过Kafka删除IM群管理员
type AdminRemoveReq struct {
g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"Kafka删除IM群管理员"`
sysin.ImGroupAdminRemoveInp
}
type AdminRemoveRes struct {
*sysin.ImGroupCommandModel
}
// MemberMuteReq 通过Kafka禁言IM群成员
type MemberMuteReq struct {
g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"`
sysin.ImGroupMemberMuteInp
}
type MemberMuteRes struct {
*sysin.ImGroupCommandModel
}
// MemberUnmuteReq 通过Kafka解除IM群成员禁言
type MemberUnmuteReq struct {
g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"`
sysin.ImGroupMemberUnmuteInp
}
type MemberUnmuteRes struct {
*sysin.ImGroupCommandModel
}
// MemberRemoveReq 通过Kafka移出IM群成员
type MemberRemoveReq struct {
g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"Kafka移出IM群成员"`
sysin.ImGroupMemberRemoveInp
}
type MemberRemoveRes struct {
*sysin.ImGroupCommandModel
}
// OwnerTransferReq 通过Kafka转让IM群主
type OwnerTransferReq struct {
g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"Kafka转让IM群主"`
sysin.ImGroupOwnerTransferInp
}
type OwnerTransferRes struct {
*sysin.ImGroupCommandModel
}
@@ -35,3 +35,8 @@ type BanUserReq struct {
sysin.ImLoginFailureBanUserInp
}
type BanUserRes struct{ *sysin.ImUserCommandModel }
type UnlockReq struct {
g.Meta `path:"/imLoginFailureLog/unlock" method:"post" tags:"IM登录失败日志" summary:"解锁登录失败锁定"`
sysin.ImLoginFailureUnlockInp
}
type UnlockRes struct{}
@@ -0,0 +1,36 @@
package immessageencryption
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/imMessageEncryption/list" method:"get" tags:"消息加密密钥" summary:"获取商户消息加密状态"`
sysin.ImMessageEncryptionListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImMessageEncryptionListModel `json:"list" dc:"数据列表"`
}
type InitializeReq struct {
g.Meta `path:"/imMessageEncryption/initialize" method:"post" tags:"消息加密密钥" summary:"初始化商户消息密钥"`
sysin.ImMessageEncryptionActionInp
}
type InitializeRes struct{}
type RotateReq struct {
g.Meta `path:"/imMessageEncryption/rotate" method:"post" tags:"消息加密密钥" summary:"轮换商户消息密钥"`
sysin.ImMessageEncryptionActionInp
}
type RotateRes struct{}
type SwitchReq struct {
g.Meta `path:"/imMessageEncryption/switch" method:"post" tags:"消息加密密钥" summary:"切换商户消息加密"`
sysin.ImMessageEncryptionSwitchInp
}
type SwitchRes struct{}
@@ -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
+10 -2
View File
@@ -33,9 +33,17 @@ type ViewRes struct {
*sysin.ImTenantViewModel
}
// EditReq 修改/新增IM租户
// AddReq 新增IM租户
type AddReq struct {
g.Meta `path:"/imTenant/add" method:"post" tags:"IM租户" summary:"新增IM租户"`
sysin.ImTenantEditInp
}
type AddRes struct{}
// EditReq 修改IM租户
type EditReq struct {
g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改/新增IM租户"`
g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改IM租户"`
sysin.ImTenantEditInp
}
@@ -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
}
+7
View File
@@ -99,6 +99,13 @@ type AdjustGroupReq struct {
type AdjustGroupRes struct{}
type FriendAddReq struct {
g.Meta `path:"/imUser/friendAdd" method:"post" tags:"IM用户" summary:"设置IM用户加好友开关"`
sysin.ImUserFriendAddInp
}
type FriendAddRes struct{}
type IPWhitelistReq struct {
g.Meta `path:"/imUser/ipWhitelist" method:"get" tags:"IM用户" summary:"获取IM用户IP白名单"`
sysin.ImUserIPWhitelistInp
@@ -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
}
+82
View File
@@ -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
View File
@@ -4,10 +4,11 @@ go 1.24.4
require (
github.com/IBM/sarama v1.45.2
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6
github.com/alibabacloud-go/tea v1.3.9
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/apache/rocketmq-client-go/v2 v2.1.2
github.com/casbin/casbin/v2 v2.108.0
+8 -7
View File
@@ -23,8 +23,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.2/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7 h1:ASXSBga98QrGMxbIThCD6jAti09gedLfvry6yJtsoBE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7/go.mod h1:TBpgqm3XofZz2LCYjZhektGPU7ArEgascyzbm4SjFo4=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
@@ -37,6 +37,8 @@ github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6 h1:UTl97mt2qfavxveqCkaVg4
github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6/go.mod h1:UWpcGrWwTbES9QW7OQ7xDffukMJ/l7lzioixIz8+lgY=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
@@ -48,16 +50,15 @@ github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy
github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.8/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.3.9 h1:bjgt1bvdY780vz/17iWNNtbXl4A77HWntWMeaUF3So0=
github.com/alibabacloud-go/tea v1.3.9/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
-1
View File
@@ -34,7 +34,6 @@ gfcli:
gJsonSupport: true
clear: false
# service: # 生成业务配置
# srcFolder: "internal/logic"
# dstFolder: "internal/service"
+54
View File
@@ -0,0 +1,54 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
const (
CultivateFlowerOnline = "online"
CultivateFlowerOffline = "offline"
CultivateFlowerActivityOnly = "activity_only"
CultivateFlowerMaintenance = "maintenance"
)
var CultivateFlowerStatusOptions = []*model.Option{
dict.GenSuccessOption(CultivateFlowerOnline, "已上架"),
dict.GenDefaultOption(CultivateFlowerOffline, "已下架"),
dict.GenPrimaryOption(CultivateFlowerActivityOnly, "仅活动"),
dict.GenWarningOption(CultivateFlowerMaintenance, "维护中"),
}
var CultivateContentStatusOptions = []*model.Option{
dict.GenWarningOption("reviewing", "待审核"),
dict.GenSuccessOption("published", "已发布"),
dict.GenErrorOption("rejected", "已拒绝"),
dict.GenDefaultOption("taken_down", "已下架"),
dict.GenDefaultOption("deleted", "已删除"),
}
var CultivateReportStatusOptions = []*model.Option{
dict.GenWarningOption("submitted", "待处理"),
dict.GenPrimaryOption("processing", "处理中"),
dict.GenSuccessOption("resolved", "已处理"),
dict.GenDefaultOption("rejected", "已驳回"),
}
var CultivateContentTypeOptions = []*model.Option{
dict.GenPrimaryOption("feed_post", "动态"),
dict.GenInfoOption("feed_comment", "评论"),
}
var CultivateReportObjectTypeOptions = []*model.Option{
dict.GenPrimaryOption("feed_post", "动态"),
dict.GenInfoOption("feed_comment", "评论"),
dict.GenDefaultOption("user", "用户"),
}
func init() {
dict.RegisterEnums("CultivateFlowerStatusOptions", "养成花种状态", CultivateFlowerStatusOptions)
dict.RegisterEnums("CultivateContentStatusOptions", "养成内容状态", CultivateContentStatusOptions)
dict.RegisterEnums("CultivateReportStatusOptions", "养成举报状态", CultivateReportStatusOptions)
dict.RegisterEnums("CultivateContentTypeOptions", "养成内容类型", CultivateContentTypeOptions)
dict.RegisterEnums("CultivateReportObjectTypeOptions", "养成举报对象类型", CultivateReportObjectTypeOptions)
}
+1
View File
@@ -13,6 +13,7 @@ const (
RegionSpilt = " / " // 地区分隔符
Unknown = "Unknown" // Unknown
SuperRoleKey = "super" // 超管角色唯一标识符,通过角色验证超管
ImPlatformRoleKey = "supreme_manager" // IM平台超管角色唯一标识符
MaxServeLogContentLen = 2048 // 最大保留服务日志内容大小
SysDefaultLanguage = "zh_CN" // 系统默认语言,当配置文件没有国际化配置时生效
)
@@ -0,0 +1,12 @@
package consts
const (
ImInternalSecretDictType = "im_internal_secret"
ImMessageRootKEKLabel = "core_material_01"
ImMessageKeyAlgorithm = "AES-256-GCM"
ImMessageKeyStatusPending = "pending"
ImMessageKeyStatusActive = "active"
ImMessageKeyStatusRetired = "retired"
ImMessageKeyStatusDisabled = "disabled"
)
@@ -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
}
+1 -1
View File
@@ -30,7 +30,7 @@ func Serve(r *ghttp.Request) {
checks := map[string]string{}
statusCode := http.StatusOK
if err := g.DB().Ctx(ctx).Raw("SELECT 1").Scan(new(int)); err != nil {
if _, err := g.DB().Ctx(ctx).Raw("SELECT 1").One(); err != nil {
checks["mysql"] = err.Error()
statusCode = http.StatusServiceUnavailable
} else {
@@ -2,10 +2,14 @@ package imconfig
import (
"context"
"encoding/json"
api "hotgo/api/admin/imconfiginternal"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/immoderation"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
@@ -14,6 +18,18 @@ import (
"github.com/gogf/gf/v2/util/gconv"
)
const defaultModerationTenantCode = "default"
var defaultModerationScenes = map[string]bool{
"user_nickname": true, "user_signature": true, "group_name": true,
"group_nickname": true, "private_message": true, "group_message": true,
"group_announcement": true,
}
var defaultModerationRiskActions = map[string]string{
"high": "block", "medium": "block", "low": "audit_only", "none": "allow",
}
var Config = cConfig{}
type cConfig struct{}
@@ -91,6 +107,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,
-22
View File
@@ -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.
+40 -22
View File
@@ -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)
}
+110 -16
View File
@@ -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)
}
}
+14 -1
View File
@@ -41,7 +41,7 @@ func Authorize(r *ghttp.Request) bool {
signature := strings.TrimSpace(r.Header.Get(headerSignature))
expectedService := g.Cfg().MustGet(ctx, "imConfigApi.serviceName", "im-server").String()
secret := configuredSecret(r)
if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || serviceName != expectedService || secret == "" {
if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || !isAllowedService(serviceName, expectedService) || secret == "" {
writeAuthError(r, http.StatusUnauthorized, "internal signature required")
return false
}
@@ -85,6 +85,19 @@ func Authorize(r *ghttp.Request) bool {
return true
}
func isAllowedService(serviceName, expectedServices string) bool {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
return false
}
for _, expected := range strings.Split(expectedServices, ",") {
if serviceName == strings.TrimSpace(expected) {
return true
}
}
return false
}
func configuredSecret(r *ghttp.Request) string {
ctx := r.GetCtx()
secret := g.Cfg().MustGet(ctx, "imConfigApi.serviceSecret").String()
@@ -0,0 +1,76 @@
package immessagecrypto
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/errors/gerror"
)
func GenerateDEK() ([]byte, error) {
key := make([]byte, encrypt.AESGCMKeySize)
if _, err := rand.Read(key); err != nil {
return nil, gerror.Wrap(err, "生成商户消息密钥失败")
}
return key, nil
}
func WrapDEK(ctx context.Context, tenantID int64, version uint, dek []byte) (ciphertext, nonce []byte, err error) {
kek, err := loadRootKEK(ctx)
if err != nil {
return nil, nil, err
}
return encrypt.AESGCMEncrypt(dek, kek, keyAAD(tenantID, version))
}
func UnwrapDEK(ctx context.Context, tenantID int64, version uint, ciphertext, nonce []byte) ([]byte, error) {
kek, err := loadRootKEK(ctx)
if err != nil {
return nil, err
}
dek, err := encrypt.AESGCMDecrypt(ciphertext, nonce, kek, keyAAD(tenantID, version))
if err != nil {
return nil, gerror.Wrap(err, "解包商户消息密钥失败")
}
if len(dek) != encrypt.AESGCMKeySize {
return nil, gerror.New("商户消息密钥长度不正确")
}
return dek, nil
}
func EncodeDEK(dek []byte) string {
return base64.StdEncoding.EncodeToString(dek)
}
func loadRootKEK(ctx context.Context) ([]byte, error) {
columns := dao.SysDictData.Columns()
value, err := dao.SysDictData.Ctx(ctx).
Fields(columns.Value).
Where(columns.Type, consts.ImInternalSecretDictType).
Where(columns.Label, consts.ImMessageRootKEKLabel).
Where(columns.Status, consts.StatusEnabled).
Value()
if err != nil {
return nil, gerror.Wrap(err, "读取消息根密钥失败")
}
if value.IsEmpty() {
return nil, gerror.New("消息根密钥未初始化")
}
kek, err := base64.StdEncoding.DecodeString(value.String())
if err != nil || len(kek) != encrypt.AESGCMKeySize {
return nil, gerror.New("消息根密钥格式不正确")
}
return kek, nil
}
func keyAAD(tenantID int64, version uint) []byte {
aad := make([]byte, 16)
binary.BigEndian.PutUint64(aad[:8], uint64(tenantID))
binary.BigEndian.PutUint64(aad[8:], uint64(version))
return aad
}
@@ -0,0 +1,57 @@
package immoderation
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
)
type AliyunConfig struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
Service string
Timeout time.Duration
}
type CheckResult struct {
RequestID string
Latency time.Duration
}
func CheckAliyunText(ctx context.Context, config AliyunConfig) (*CheckResult, error) {
if strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" {
return nil, errors.New("AccessKey ID或AccessKey Secret未配置")
}
clientConfig := &openapi.Config{AccessKeyId: dara.String(config.AccessKeyID), AccessKeySecret: dara.String(config.AccessKeySecret), RegionId: dara.String(config.RegionID), Protocol: dara.String("https")}
if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" {
clientConfig.Endpoint = dara.String(endpoint)
}
client, err := green.NewClient(clientConfig)
if err != nil {
return nil, err
}
if config.Timeout <= 0 {
config.Timeout = 1500 * time.Millisecond
}
ctx, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
params, _ := json.Marshal(map[string]string{"content": "内容审核连通性测试", "dataId": "admin-config-check"})
started := time.Now()
resp, err := client.TextModerationPlusWithContext(ctx, &green.TextModerationPlusRequest{Service: dara.String(config.Service), ServiceParameters: dara.String(string(params))}, &dara.RuntimeOptions{})
latency := time.Since(started)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || dara.Int32Value(resp.Body.Code) != 200 {
return nil, errors.New("阿里云内容审核返回失败")
}
return &CheckResult{RequestID: dara.StringValue(resp.Body.RequestId), Latency: latency}, nil
}
@@ -0,0 +1,63 @@
package immoderation
import (
"context"
"hotgo/internal/dao"
"hotgo/internal/model/entity"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
)
const (
AliyunConfigGroup = "im_third_party"
AliyunAccessKeyIDKey = "aliyun_access_key_id"
AliyunAccessKeySecretKey = "aliyun_access_key_secret"
AliyunGreenRegionIDKey = "aliyun_green_region_id"
AliyunGreenEndpointKey = "aliyun_green_endpoint"
DefaultAliyunGreenRegionID = "cn-shanghai"
DefaultAliyunGreenEndpoint = "green-cip.cn-shanghai.aliyuncs.com"
)
type AliyunGlobalConfig struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
}
func LoadAliyunGlobalConfig(ctx context.Context) (config AliyunGlobalConfig, err error) {
columns := dao.SysConfig.Columns()
keys := []string{
AliyunAccessKeyIDKey,
AliyunAccessKeySecretKey,
AliyunGreenRegionIDKey,
AliyunGreenEndpointKey,
}
var rows []*entity.SysConfig
if err = dao.SysConfig.Ctx(ctx).
Fields(columns.Key, columns.Value).
Where(columns.Group, AliyunConfigGroup).
Where(columns.Status, 1).
WhereIn(columns.Key, keys).
Scan(&rows); err != nil {
return config, gerror.Wrap(err, "获取阿里云内容审核公共配置失败")
}
values := make(map[string]string, len(rows))
for _, row := range rows {
values[row.Key] = strings.TrimSpace(row.Value)
}
return AliyunGlobalConfig{
AccessKeyID: values[AliyunAccessKeyIDKey],
AccessKeySecret: values[AliyunAccessKeySecretKey],
RegionID: firstValue(values[AliyunGreenRegionIDKey], DefaultAliyunGreenRegionID),
Endpoint: firstValue(values[AliyunGreenEndpointKey], DefaultAliyunGreenEndpoint),
}, nil
}
func firstValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
+28 -30
View File
@@ -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 != "" {
+5 -5
View File
@@ -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)
}
}
+54 -5
View File
@@ -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)
+10 -5
View File
@@ -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 获取菜单列表
+48 -9
View File
@@ -37,6 +37,35 @@ func init() {
service.RegisterAdminRole(NewAdminRole())
}
func isAdminScopeSuper(ctx context.Context, memberId, roleId int64) bool {
if service.AdminMember().VerifySuperId(ctx, memberId) {
return true
}
if memberId == contexts.GetUserId(ctx) && contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey {
return true
}
if roleId <= 0 {
return false
}
value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Key).WherePri(roleId).Value()
if err != nil {
g.Log().Warningf(ctx, "query admin role key failed, roleId:%d err:%+v", roleId, err)
return false
}
return value.String() == consts.ImPlatformRoleKey
}
func getSuperRoleId(ctx context.Context) (int64, error) {
value, err := dao.AdminRole.Ctx(ctx).Fields(dao.AdminRole.Columns().Id).Where(dao.AdminRole.Columns().Key, consts.SuperRoleKey).Value()
if err != nil {
return 0, err
}
return value.Int64(), nil
}
// Verify 验证权限
func (s *sAdminRole) Verify(ctx context.Context, path, method string) bool {
var (
@@ -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
+3 -1
View File
@@ -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))
}
}
+46 -1
View File
@@ -13,6 +13,7 @@ import (
"hotgo/internal/library/dict"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -30,11 +31,18 @@ func init() {
// Delete 删除
func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error {
oldTypes := s.getTypesByIds(ctx, in.Id)
if count, err := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); err != nil {
return gerror.Wrap(err, consts.ErrorORM)
} else if count > 0 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
_, err := dao.SysDictData.Ctx(ctx).Where("id", in.Id).Delete()
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, oldTypes)
return nil
}
@@ -42,11 +50,18 @@ func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp)
func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err error) {
// 修改
if in.Id > 0 {
if count, queryErr := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); queryErr != nil {
return gerror.Wrap(queryErr, consts.ErrorORM)
} else if count > 0 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
oldTypes := s.getTypesByIds(ctx, in.Id)
_, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataUpdateFields{}).WherePri(in.Id).Data(in).Update()
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, append(oldTypes, in.Type))
return nil
}
@@ -60,18 +75,45 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err
if in.Type == "" {
return gerror.Wrap(err, "类型选择无效,请检查")
}
if in.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
_, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataInsertFields{}).Data(in).OmitEmptyData().Insert()
if err != nil {
err = gerror.Wrap(err, consts.ErrorORM)
return err
}
s.publishDictUpdated(ctx, []string{in.Type})
return nil
}
// getTypesByIds 查询指定字典数据ID的类型标识,用于变更后失效缓存
func (s *sSysDictData) getTypesByIds(ctx context.Context, ids interface{}) []string {
columns, err := dao.SysDictData.Ctx(ctx).Where(dao.SysDictData.Columns().Id, ids).Fields(dao.SysDictData.Columns().Type).Array()
if err != nil {
g.Log().Warningf(ctx, "查询字典类型失败 err:%+v", err)
return nil
}
return g.NewVar(columns).Strings()
}
// publishDictUpdated 发布字典变更事件,通知 im-server 实时失效字典配置缓存
func (s *sSysDictData) publishDictUpdated(ctx context.Context, types []string) {
for _, t := range types {
t = strings.TrimSpace(t)
if t == "" || t == consts.ImInternalSecretDictType {
continue
}
if err := publishImPolicyEvent(ctx, 0, "*", consts.ImConfigEventDictionaryUpdated, t, 0); err != nil {
g.Log().Warningf(ctx, "发布字典变更事件失败 type:%s err:%+v", t, err)
}
}
}
// List 获取列表
func (s *sSysDictData) List(ctx context.Context, in *sysin.DictDataListInp) (list []*sysin.DictDataListModel, totalCount int, err error) {
mod := dao.SysDictData.Ctx(ctx)
mod := dao.SysDictData.Ctx(ctx).WhereNot(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType)
// 类型ID
if in.TypeID > 0 {
types, err := s.GetTypes(ctx, in.TypeID)
@@ -151,6 +193,9 @@ func (s *sSysDictData) GetTypes(ctx context.Context, id int64) (types []string,
// Select 获取列表
func (s *sSysDictData) Select(ctx context.Context, in *sysin.DataSelectInp) (list sysin.DataSelectModel, err error) {
if in.Type == consts.ImInternalSecretDictType {
return nil, gerror.New("内部密钥字典禁止通过通用接口读取")
}
// 优先从内置字典中获取
options, err := dict.GetOptions(ctx, in.Type)
if err == nil {
+17 -2
View File
@@ -31,7 +31,7 @@ func init() {
// Tree 树
func (s *sSysDictType) Tree(ctx context.Context) (list []*sysin.DictTypeTree, err error) {
var (
mod = dao.SysDictType.Ctx(ctx)
mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType)
models []*entity.SysDictType
)
@@ -55,6 +55,9 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp)
err = gerror.New("数据不存在或已删除!")
return
}
if models.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
exist, err := dao.SysDictData.Ctx(ctx).Where("type", models.Type).One()
if err != nil {
@@ -83,6 +86,18 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp)
// Edit 修改/新增
func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err error) {
if in.Type == consts.ImInternalSecretDictType {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
if in.Id > 0 {
count, queryErr := dao.SysDictType.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType).Count()
if queryErr != nil {
return gerror.Wrap(queryErr, consts.ErrorORM)
}
if count > 0 {
return gerror.New("内部密钥字典禁止通过通用接口操作")
}
}
if err = hgorm.IsUnique(ctx, &dao.SysDictType, g.Map{dao.SysDictType.Columns().Name: in.Name}, "名称已存在", in.Id); err != nil {
return
}
@@ -105,7 +120,7 @@ func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err
// TreeSelect 获取类型关系树选项
func (s *sSysDictType) TreeSelect(ctx context.Context, in *sysin.DictTreeSelectInp) (list []*sysin.DictTypeTree, err error) {
var (
mod = dao.SysDictType.Ctx(ctx)
mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType)
models []*entity.SysDictType
)
+60 -6
View File
@@ -5,7 +5,6 @@ import (
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/service"
@@ -18,19 +17,34 @@ import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"strings"
)
type imAdminScope struct {
TenantId int64
OperatorId int64
RoleKey string
RequestId string
}
const (
imAdminOperationAuditTable = "im_admin_operation_audit"
)
func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imadmin.AdminScope, err error) {
func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imAdminScope, err error) {
return resolveImAdminScopeWithAll(ctx, selectedTenantId, false)
}
func resolveImAdminScopeWithAll(ctx context.Context, selectedTenantId int64, allowAll bool) (scope imAdminScope, err error) {
operatorId := contexts.GetUserId(ctx)
scope.OperatorId = operatorId
scope.RoleKey = contexts.GetRoleKey(ctx)
scope.RequestId = gctx.CtxId(ctx)
if isImPlatformAdmin(ctx, operatorId) {
if selectedTenantId <= 0 {
if allowAll {
return scope, nil
}
return scope, gerror.New("请选择IM租户")
}
if err = ensureImTenantActive(ctx, selectedTenantId); err != nil {
@@ -40,7 +54,7 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima
return scope, nil
}
if !contexts.IsTenantDept(ctx) {
if !contexts.IsTenantDept(ctx) && !(contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) > 0) {
return scope, gerror.New("当前账号无权管理IM租户")
}
tenantId, err := resolveImAdminBoundTenant(ctx)
@@ -55,10 +69,13 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima
}
func isImPlatformAdmin(ctx context.Context, operatorId int64) bool {
if contexts.IsCompanyDept(ctx) {
if service.AdminMember().VerifySuperId(ctx, operatorId) {
return true
}
return service.AdminMember().VerifySuperId(ctx, operatorId)
if contexts.GetRoleKey(ctx) == consts.ImPlatformRoleKey {
return true
}
return contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) <= 0
}
func resolveImAdminBoundTenant(ctx context.Context) (int64, error) {
@@ -69,9 +86,46 @@ func resolveImAdminBoundTenant(ctx context.Context) (int64, error) {
return tenantId, nil
}
// ensureImRecordTenantAllowed 校验目标记录/租户是否在当前操作者允许范围内。
// 平台管理员(超管/平台角色/未绑定租户的公司部门)可访问任意租户的记录;
// 其余账号强制限定为自己绑定的租户(与 resolveImAdminScope 的范围逻辑一致)。
// 适用于入参不含租户ID、需按已查出的记录租户做后置校验的场景。
func ensureImRecordTenantAllowed(ctx context.Context, recordTenantId int64) error {
if isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return nil
}
if !contexts.IsTenantDept(ctx) && !(contexts.IsCompanyDept(ctx) && contexts.GetTenantId(ctx) > 0) {
return gerror.New("当前账号无权管理IM租户")
}
boundTenantId, err := resolveImAdminBoundTenant(ctx)
if err != nil {
return err
}
if boundTenantId != recordTenantId {
return gerror.New("无权访问其他商户的IM数据")
}
return nil
}
// lookupImTenantByCode 按租户编码查询租户(用于入参只有租户编码的管理端接口)
func lookupImTenantByCode(ctx context.Context, code string) (tenant *entity.ImTenant, err error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, gerror.New("请选择IM租户")
}
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().Code, code).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
return nil, gerror.New("IM租户不存在")
}
return tenant, nil
}
func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) {
var tenant *entity.ImTenant
if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
@@ -5,15 +5,25 @@ import (
"testing"
"hotgo/internal/consts"
_ "hotgo/internal/logic/admin"
"hotgo/internal/model"
)
func TestCompanyDepartmentIsImPlatformAdmin(t *testing.T) {
func TestBoundCompanyDepartmentUsesBoundTenant(t *testing.T) {
ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{
User: &model.Identity{Id: 14, TenantId: 1, DeptType: consts.DeptTypeCompany, RoleKey: "imManage"},
})
if isImPlatformAdmin(ctx, 14) {
t.Fatal("expected bound company department IM manager to use its bound tenant")
}
}
func TestUnboundCompanyDepartmentIsImPlatformAdmin(t *testing.T) {
ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{
User: &model.Identity{Id: 14, DeptType: consts.DeptTypeCompany, RoleKey: "imManage"},
})
if !isImPlatformAdmin(ctx, 14) {
t.Fatal("expected company department IM manager to use platform tenant selection")
t.Fatal("expected unbound company department IM manager to use platform tenant selection")
}
}
@@ -31,6 +41,14 @@ func TestResolveImAdminBoundTenant(t *testing.T) {
},
want: 2,
},
{
name: "company department with tenant binding",
identity: &model.Identity{
TenantId: 1,
DeptType: consts.DeptTypeCompany,
},
want: 1,
},
}
for _, tt := range tests {
+40 -21
View File
@@ -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
+3 -24
View File
@@ -10,18 +10,13 @@ import (
"encoding/json"
"fmt"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imkafka"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"net/url"
"strconv"
"github.com/gogf/gf/v2/errors/gerror"
)
type sSysImDeadLetter struct{}
@@ -39,7 +34,7 @@ func (s *sSysImDeadLetter) List(ctx context.Context, in *sysin.ImDeadLetterListI
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -81,7 +76,7 @@ func (s *sSysImDeadLetter) View(ctx context.Context, in *sysin.ImDeadLetterViewI
if err = in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -120,7 +115,7 @@ func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.I
if err := in.Filter(ctx); err != nil {
return nil, err
}
tenant, err := s.getTenant(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
@@ -162,22 +157,6 @@ func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.I
}, nil
}
func (s *sSysImDeadLetter) getTenant(ctx context.Context, tenantId int64) (tenant *entity.ImTenant, err error) {
if tenantId <= 0 {
return nil, gerror.New("请选择IM租户")
}
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
return nil, gerror.New("IM租户不存在")
}
if tenant.State != consts.ImTenantStateActive {
return nil, gerror.New("IM租户未启用")
}
return tenant, nil
}
func convertDeadLetterModel(in *sysin.ImDeadLetterListModelRaw) *sysin.ImDeadLetterListModel {
if in == nil {
return &sysin.ImDeadLetterListModel{}
@@ -5,16 +5,22 @@ import (
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/immoderation"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"strings"
"time"
)
type sSysImExternalConfig struct{}
@@ -36,7 +42,13 @@ func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, 0, gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
mod := s.model(ctx).Where("tenant_id", tenant.Id)
if tenant.Code != "default" {
mod = mod.WhereNot("service", consts.ImExternalServiceModeration)
}
if in.Service != "" {
mod = mod.Where("service", in.Service)
}
@@ -68,6 +80,9 @@ func (s *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return nil, err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, gerror.New("第一阶段仅默认商户可查看内容审核服务")
}
if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!")
}
@@ -91,6 +106,34 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以维护内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration {
if in.Provider != "aliyun_green" {
return gerror.New("内容审核服务商必须为aliyun_green")
}
in.Endpoint = ""
if in.ConfigJson == nil {
in.ConfigJson = gjson.New(map[string]any{"nickname_service": "nickname_detection_pro", "chat_service": "chat_detection_pro", "timeout_ms": 1500, "failure_mode": "allow"})
}
config := in.ConfigJson.Map()
if timeout := gconv.Int(config["timeout_ms"]); timeout < 200 || timeout > 10000 {
return gerror.New("内容审核超时必须为200至10000毫秒")
}
if mode := gconv.String(config["failure_mode"]); mode != "allow" && mode != "block" {
return gerror.New("内容审核异常策略不合法")
}
in.ConfigJson = gjson.New(map[string]any{
"nickname_service": firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"),
"chat_service": firstExternalValue(gconv.String(config["chat_service"]), "chat_detection_pro"),
"timeout_ms": gconv.Int(config["timeout_ms"]),
"failure_mode": gconv.String(config["failure_mode"]),
})
}
configJson := "{}"
if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" {
configJson = in.ConfigJson.String()
@@ -106,6 +149,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" {
data["secret_json"] = in.SecretJson.String()
}
if in.Service == consts.ImExternalServiceModeration {
data["secret_json"] = "{}"
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新IM外部服务配置失败,请稍后重试!")
@@ -113,6 +159,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
_ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{
"service": in.Service, "provider": in.Provider, "enabled": in.Enabled,
})
if in.Service == consts.ImExternalServiceModeration {
_ = publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "third_party.updated", "moderation", 0)
}
return nil
}
@@ -127,6 +176,12 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可检查内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以检查内容审核服务")
}
var row gdb.Record
if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil {
@@ -137,6 +192,8 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" {
status = consts.ImExternalConfigStatusUnconfigured
result = "未启用或服务商为空"
} else if in.Service == consts.ImExternalServiceModeration {
status, result = s.checkModeration(ctx, row)
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).
@@ -150,6 +207,45 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
return nil
}
func (s *sSysImExternalConfig) checkModeration(ctx context.Context, row gdb.Record) (string, string) {
if row["provider"].String() != "aliyun_green" {
return consts.ImExternalConfigStatusUnconfigured, "内容审核服务商必须为aliyun_green"
}
config := map[string]any{}
_ = json.Unmarshal([]byte(row["config_json"].String()), &config)
globalConfig, err := immoderation.LoadAliyunGlobalConfig(ctx)
if err != nil {
return consts.ImExternalConfigStatusUnconfigured, sanitizeExternalError(err)
}
timeoutMS := gconv.Int(config["timeout_ms"])
if timeoutMS <= 0 {
timeoutMS = 1500
}
check, err := immoderation.CheckAliyunText(ctx, immoderation.AliyunConfig{
AccessKeyID: globalConfig.AccessKeyID, AccessKeySecret: globalConfig.AccessKeySecret,
RegionID: globalConfig.RegionID, Endpoint: globalConfig.Endpoint,
Service: firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"), Timeout: time.Duration(timeoutMS) * time.Millisecond,
})
if err != nil {
return consts.ImExternalConfigStatusUnconfigured, "连通性检查失败:" + sanitizeExternalError(err)
}
return consts.ImExternalConfigStatusConfigured, fmt.Sprintf("检查成功,耗时%dms,RequestID=%s", check.Latency.Milliseconds(), check.RequestID)
}
func firstExternalValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func sanitizeExternalError(err error) string {
value := strings.ReplaceAll(err.Error(), "\n", " ")
if len([]rune(value)) > 120 {
value = string([]rune(value)[:120])
}
return value
}
func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model {
return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields(
"tenant_id",
@@ -2,17 +2,13 @@ package sys
import (
"context"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imFeedbackConfigTable = "im_feedback_config"
type sSysImFeedbackConfig struct{}
func NewSysImFeedbackConfig() *sSysImFeedbackConfig { return &sSysImFeedbackConfig{} }
@@ -25,16 +21,14 @@ func (s *sSysImFeedbackConfig) Get(ctx context.Context, in *sysin.ImFeedbackConf
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.ensureDefault(ctx, db); err != nil {
res = &sysin.ImFeedbackConfigModel{}
if err = imworkbench.DefaultClient(ctx).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
}
+63 -53
View File
@@ -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
}
+23 -6
View File
@@ -116,7 +116,7 @@ func (s *sSysImInvite) Create(ctx context.Context, in *sysin.ImInviteCreateInp)
expiresAt := in.ExpiresAt.Time.UTC().Round(time.Millisecond)
var item imInviteCodeResponse
if key := strings.TrimSpace(in.IdempotencyKey); key != "" {
batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key)
batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key, 0, "")
if createErr != nil {
err = createErr
} else if len(codes) > 0 {
@@ -150,15 +150,27 @@ func (s *sSysImInvite) BatchCreate(ctx context.Context, in *sysin.ImInviteBatchC
if err != nil {
return nil, err
}
var boundUserLabel string
if in.UserId > 0 {
tenant, terr := resolveImAdminTenant(ctx, scope.TenantId)
if terr != nil {
return nil, terr
}
label, lerr := inviteBoundUserLabel(ctx, tenant, in.UserId)
if lerr != nil {
return nil, lerr
}
boundUserLabel = label
}
key := strings.TrimSpace(in.IdempotencyKey)
batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key)
batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key, in.UserId, boundUserLabel)
result := "success"
if err != nil {
result = "failed"
} else {
res = convertInviteBatch(batch)
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark})
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark, "boundUserId": in.UserId})
return res, err
}
@@ -358,7 +370,7 @@ func createSingleInvite(ctx context.Context, db gdb.DB, tenantID, operatorID int
return imInviteCodeResponse{}, gerror.New("无法生成不重复的邀请码,请重试")
}
func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string) (*imInviteBatchResponse, []imInviteCodeResponse, error) {
func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string, boundUserID int64, boundUserLabel string) (*imInviteBatchResponse, []imInviteCodeResponse, error) {
if existing, err := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); err == nil {
if !sameInviteBatch(existing, quantity, expiresAt, remark) {
return nil, nil, gerror.New("幂等键已被其他批量生成请求使用")
@@ -402,11 +414,16 @@ func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int6
}
for i := range codes {
if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,'active',?,0,0,NULL,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now); txErr != nil {
(id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at,bound_user_id,bound_user_label)
VALUES(?,?,?,?,'active',?,0,0,NULL,?,?,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now, boundUserID, boundUserLabel); txErr != nil {
return txErr
}
codes[i].TenantID, codes[i].Status, codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, codes[i].CreatedAt = tenantID, "active", expiresAt, operatorID, remark, now
if boundUserID > 0 {
id := boundUserID
codes[i].BoundUserID = &id
codes[i].BoundUserLabel = boundUserLabel
}
}
return nil
})
+29 -8
View File
@@ -33,7 +33,12 @@ func (s *sSysImKafkaCommand) List(ctx context.Context, in *sysin.ImKafkaCommandL
if err = in.Filter(ctx); err != nil {
return
}
where, args := buildImKafkaCommandWhere(in)
// 强制租户范围:商户绑定账号忽略前端所选租户,平台管理员使用所选租户
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return
}
where, args := buildImKafkaCommandWhere(in, scope.TenantId)
db := imtenant.RegistryDB()
totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_admin_commands "+where, args...)
if err != nil {
@@ -91,16 +96,16 @@ func (s *sSysImKafkaCommand) View(ctx context.Context, in *sysin.ImKafkaCommandV
if err = row.Struct(&res); err != nil {
return nil, gerror.Wrap(err, "解析IM后台命令详情失败,请稍后重试!")
}
// 后置校验记录归属租户是否在操作者允许范围内
if err = ensureImRecordTenantAllowed(ctx, res.TenantId); err != nil {
return nil, err
}
return
}
func buildImKafkaCommandWhere(in *sysin.ImKafkaCommandListInp) (string, []any) {
parts := []string{"1=1"}
args := []any{}
if in.TenantId > 0 {
parts = append(parts, "tenant_id=?")
args = append(args, in.TenantId)
}
func buildImKafkaCommandWhere(in *sysin.ImKafkaCommandListInp, scopeTenantId int64) (string, []any) {
parts := []string{"tenant_id=?"}
args := []any{scopeTenantId}
if in.CommandId != "" {
parts = append(parts, "command_id LIKE ?")
args = append(args, "%"+in.CommandId+"%")
@@ -136,6 +141,14 @@ func (s *sSysImKafkaCommand) CacheInvalidate(ctx context.Context, in *sysin.ImKa
if err = in.Filter(ctx); err != nil {
return nil, err
}
// 校验目标租户是否在操作者允许范围内
tenant, err := lookupImTenantByCode(ctx, in.TenantCode)
if err != nil {
return nil, err
}
if err = ensureImRecordTenantAllowed(ctx, tenant.Id); err != nil {
return nil, err
}
user := contexts.GetUser(ctx)
actor := &imkafka.Actor{
Type: "admin",
@@ -170,6 +183,14 @@ func (s *sSysImKafkaCommand) WorkbenchEvent(ctx context.Context, in *sysin.ImKaf
if err = in.Filter(ctx); err != nil {
return nil, err
}
// 校验目标租户是否在操作者允许范围内
tenant, err := lookupImTenantByCode(ctx, in.TenantCode)
if err != nil {
return nil, err
}
if err = ensureImRecordTenantAllowed(ctx, tenant.Id); err != nil {
return nil, err
}
user := contexts.GetUser(ctx)
actor := &imkafka.Actor{
Type: "admin",
@@ -5,6 +5,7 @@ import (
"fmt"
"hotgo/internal/consts"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imkafka"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
@@ -15,6 +16,7 @@ import (
"strconv"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
@@ -128,6 +130,26 @@ func (s *sSysImLoginFailureLog) AddBlacklist(ctx context.Context, in *sysin.ImLo
})
}
func (s *sSysImLoginFailureLog) Unlock(ctx context.Context, in *sysin.ImLoginFailureUnlockInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return err
}
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/login-lockouts/release"
var out struct {
AccountHash string `json:"account_hash"`
Status string `json:"status"`
}
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]string{"account_hash": in.AccountHash}, &out); err != nil {
return err
}
g.Log().Infof(ctx, "登录锁定已解锁 tenantId:%d accountHash:%s adminId:%d status:%s", tenant.Id, in.AccountHash, contexts.GetUserId(ctx), out.Status)
return nil
}
func (s *sSysImLoginFailureLog) BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (*sysin.ImUserCommandModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
+8 -61
View File
@@ -2,17 +2,13 @@ package sys
import (
"context"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imMailboxConfigTable = "im_mailbox_config"
type sSysImMailboxConfig struct{}
func NewSysImMailboxConfig() *sSysImMailboxConfig { return &sSysImMailboxConfig{} }
@@ -25,16 +21,14 @@ func (s *sSysImMailboxConfig) Get(ctx context.Context, in *sysin.ImMailboxConfig
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.ensureDefault(ctx, db); err != nil {
res = &sysin.ImMailboxConfigModel{}
if err = imworkbench.DefaultClient(ctx).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
}
+1 -1
View File
@@ -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
+197 -95
View File
@@ -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
+415 -129
View File
@@ -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
}
+81 -27
View File
@@ -206,7 +206,12 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []
values.Set("user_ids", strings.Join(userIds, ","))
}
if in.UserId != "" {
values.Set("account_no", in.UserId)
// 长数字按用户ID(u.id)精确匹配,短数字按后台展示编号(account_no)匹配
if len(in.UserId) >= 12 {
values.Set("user_id", in.UserId)
} else {
values.Set("account_no", in.UserId)
}
}
if in.Nickname != "" {
values.Set("nickname", in.Nickname)
@@ -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
})
+40 -103
View File
@@ -2,18 +2,15 @@ package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/imworkbench"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"net/url"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const imWorkbenchAppsTable = "im_workbench_apps"
type sSysImWorkbench struct{}
func NewSysImWorkbench() *sSysImWorkbench { return &sSysImWorkbench{} }
@@ -26,47 +23,46 @@ func (s *sSysImWorkbench) List(ctx context.Context, in *sysin.ImWorkbenchAppList
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureDefaultApps(ctx, db); err != nil {
return
}
mod := s.model(ctx, db)
values := url.Values{}
values.Set("page", intToString(in.Page))
values.Set("per_page", intToString(in.PerPage))
if in.Code != "" {
mod = mod.WhereLike("code", "%"+in.Code+"%")
values.Set("code", in.Code)
}
if in.Name != "" {
mod = mod.WhereLike("name", "%"+in.Name+"%")
values.Set("name", in.Name)
}
if in.Enabled != nil {
mod = mod.Where("enabled", *in.Enabled)
values.Set("enabled", intToString(*in.Enabled))
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("sort_order").OrderAsc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM工作台模块失败,请稍后重试!")
return
var page imworkbench.Page[sysin.ImWorkbenchAppModel]
if err = imworkbench.DefaultClient(ctx).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
}
+206
View File
@@ -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
}
+181
View File
@@ -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