Compare commits

..
38 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
160 changed files with 7433 additions and 4181 deletions
+53 -22
View File
@@ -1,37 +1,68 @@
name: Deploy im-manager (k3s test cluster)
name: Deploy im-manager production
on:
push:
branches:
- pro
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
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
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像(前端+后端一体,自包含多阶段构建)
- name: Build and push image
env:
GITHUB_SHA: ${{ github.sha }}
run: |
docker build -t git.118im.com/im_team/im-manager:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-manager:${{ steps.vars.outputs.tag }}
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: 部署到k3s测试集群
- 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: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-manager -n manager-system
kubectl rollout status deployment/im-manager -n manager-system --timeout=120s
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"
@@ -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,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{}
@@ -35,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"`
@@ -225,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"`
@@ -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
@@ -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
}
@@ -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
}
-11
View File
@@ -34,17 +34,6 @@ gfcli:
gJsonSupport: true
clear: false
- link: "mysql:root:root@tcp(127.0.0.1:3306)/cultivate?loc=Local&parseTime=true&charset=utf8mb4"
group: "cultivate"
tables: "cv_flower_catalog,cv_flower_stage_rule,cv_feed_post,cv_feed_media,cv_feed_comment,cv_feed_report,cv_content_audit_log,cv_admin_operation_log"
removePrefix: "cv_"
descriptionTag: true
noModelComment: true
jsonCase: "CamelLower"
gJsonSupport: true
clear: false
# service: # 生成业务配置
# srcFolder: "internal/logic"
# dstFolder: "internal/service"
@@ -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
}
@@ -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 {
@@ -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
}
@@ -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
}
@@ -178,17 +178,8 @@ func (c *cConfig) ModerationPolicy(ctx context.Context, req *api.ModerationPolic
return nil, err
}
res = defaultModerationPolicy(tenant)
var tenantConfig struct {
AliyunModerationEnabled int `json:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled"`
}
if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields("aliyun_moderation_enabled", "local_moderation_enabled").
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&tenantConfig); err != nil {
return nil, gerror.Wrap(err, "获取商户内容审核开关失败")
}
res.AliyunEnabled = tenantConfig.AliyunModerationEnabled == 1
res.LocalWordsEnabled = tenantConfig.LocalModerationEnabled == 1
res.AliyunEnabled = tenant.AliyunModerationEnabled == 1
res.LocalWordsEnabled = tenant.LocalModerationEnabled == 1
res.Enabled = res.AliyunEnabled || res.LocalWordsEnabled
columns := dao.ImSecurityPolicy.Columns()
var row struct {
@@ -333,18 +324,7 @@ func (c *cConfig) RealnamePolicy(ctx context.Context, req *api.RealnamePolicyReq
return nil, err
}
res = defaultRealnamePolicy(tenant.Id)
var row *struct {
RealnameReviewPolicy int `json:"realname_review_policy"`
}
if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields("realname_review_policy").
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&row); err != nil {
return nil, gerror.Wrap(err, "获取租户实名认证策略失败")
}
if row == nil {
return res, nil
}
if row.RealnameReviewPolicy == 1 {
if tenant.RealnameReviewPolicy == 1 {
res.RealnameReviewPolicy = 1
}
return res, nil
@@ -488,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("客户端平台不合法")
}
@@ -501,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"`
@@ -516,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客户端版本策略失败")
@@ -524,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,
@@ -553,8 +542,6 @@ func tenantToModel(tenant *entity.ImTenant) *api.TenantModel {
Code: tenant.Code,
Name: tenant.Name,
TenantDesc: &desc,
DBName: tenant.DbName,
DBDSN: tenant.DbDsn,
State: tenant.State,
CreatedAt: gtimeToRFC3339(tenant.CreatedAt),
CreatedBy: tenant.CreatedBy,
@@ -1,22 +0,0 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// adminOperationLogDao is the data access object for the table cv_admin_operation_log.
// You can define custom methods on it to extend its functionality as needed.
type adminOperationLogDao struct {
*internal.AdminOperationLogDao
}
var (
// AdminOperationLog is a globally accessible object for table cv_admin_operation_log operations.
AdminOperationLog = adminOperationLogDao{internal.NewAdminOperationLogDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// contentAuditLogDao is the data access object for the table cv_content_audit_log.
// You can define custom methods on it to extend its functionality as needed.
type contentAuditLogDao struct {
*internal.ContentAuditLogDao
}
var (
// ContentAuditLog is a globally accessible object for table cv_content_audit_log operations.
ContentAuditLog = contentAuditLogDao{internal.NewContentAuditLogDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// feedCommentDao is the data access object for the table cv_feed_comment.
// You can define custom methods on it to extend its functionality as needed.
type feedCommentDao struct {
*internal.FeedCommentDao
}
var (
// FeedComment is a globally accessible object for table cv_feed_comment operations.
FeedComment = feedCommentDao{internal.NewFeedCommentDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// feedMediaDao is the data access object for the table cv_feed_media.
// You can define custom methods on it to extend its functionality as needed.
type feedMediaDao struct {
*internal.FeedMediaDao
}
var (
// FeedMedia is a globally accessible object for table cv_feed_media operations.
FeedMedia = feedMediaDao{internal.NewFeedMediaDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// feedPostDao is the data access object for the table cv_feed_post.
// You can define custom methods on it to extend its functionality as needed.
type feedPostDao struct {
*internal.FeedPostDao
}
var (
// FeedPost is a globally accessible object for table cv_feed_post operations.
FeedPost = feedPostDao{internal.NewFeedPostDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// feedReportDao is the data access object for the table cv_feed_report.
// You can define custom methods on it to extend its functionality as needed.
type feedReportDao struct {
*internal.FeedReportDao
}
var (
// FeedReport is a globally accessible object for table cv_feed_report operations.
FeedReport = feedReportDao{internal.NewFeedReportDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// flowerCatalogDao is the data access object for the table cv_flower_catalog.
// You can define custom methods on it to extend its functionality as needed.
type flowerCatalogDao struct {
*internal.FlowerCatalogDao
}
var (
// FlowerCatalog is a globally accessible object for table cv_flower_catalog operations.
FlowerCatalog = flowerCatalogDao{internal.NewFlowerCatalogDao()}
)
// Add your custom methods and functionality below.
-22
View File
@@ -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"
)
// flowerStageRuleDao is the data access object for the table cv_flower_stage_rule.
// You can define custom methods on it to extend its functionality as needed.
type flowerStageRuleDao struct {
*internal.FlowerStageRuleDao
}
var (
// FlowerStageRule is a globally accessible object for table cv_flower_stage_rule operations.
FlowerStageRule = flowerStageRuleDao{internal.NewFlowerStageRuleDao()}
)
// Add your custom methods and functionality below.
-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.
@@ -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"
)
// AdminOperationLogDao is the data access object for the table cv_admin_operation_log.
type AdminOperationLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns AdminOperationLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// AdminOperationLogColumns defines and stores column names for the table cv_admin_operation_log.
type AdminOperationLogColumns struct {
Id string //
TenantId string //
OperatorId string //
OperationType string //
TargetType string //
TargetId string //
RequestId string //
BeforeSnapshot string //
AfterSnapshot string //
Reason string //
IpAddress string //
UserAgent string //
CreatedAt string //
}
// adminOperationLogColumns holds the columns for the table cv_admin_operation_log.
var adminOperationLogColumns = AdminOperationLogColumns{
Id: "id",
TenantId: "tenant_id",
OperatorId: "operator_id",
OperationType: "operation_type",
TargetType: "target_type",
TargetId: "target_id",
RequestId: "request_id",
BeforeSnapshot: "before_snapshot",
AfterSnapshot: "after_snapshot",
Reason: "reason",
IpAddress: "ip_address",
UserAgent: "user_agent",
CreatedAt: "created_at",
}
// NewAdminOperationLogDao creates and returns a new DAO object for table data access.
func NewAdminOperationLogDao(handlers ...gdb.ModelHandler) *AdminOperationLogDao {
return &AdminOperationLogDao{
group: "cultivate",
table: "cv_admin_operation_log",
columns: adminOperationLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *AdminOperationLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *AdminOperationLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *AdminOperationLogDao) Columns() AdminOperationLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *AdminOperationLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *AdminOperationLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *AdminOperationLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -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"
)
// ContentAuditLogDao is the data access object for the table cv_content_audit_log.
type ContentAuditLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns ContentAuditLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// ContentAuditLogColumns defines and stores column names for the table cv_content_audit_log.
type ContentAuditLogColumns struct {
Id string //
TenantId string //
ObjectType string //
ObjectId string //
AuditRequestId string //
AuditSource string //
StatusBefore string //
StatusAfter string //
ResultCode string //
RiskLabels string //
OperatorId string //
OccurredAt string //
CreatedAt string //
}
// contentAuditLogColumns holds the columns for the table cv_content_audit_log.
var contentAuditLogColumns = ContentAuditLogColumns{
Id: "id",
TenantId: "tenant_id",
ObjectType: "object_type",
ObjectId: "object_id",
AuditRequestId: "audit_request_id",
AuditSource: "audit_source",
StatusBefore: "status_before",
StatusAfter: "status_after",
ResultCode: "result_code",
RiskLabels: "risk_labels",
OperatorId: "operator_id",
OccurredAt: "occurred_at",
CreatedAt: "created_at",
}
// NewContentAuditLogDao creates and returns a new DAO object for table data access.
func NewContentAuditLogDao(handlers ...gdb.ModelHandler) *ContentAuditLogDao {
return &ContentAuditLogDao{
group: "cultivate",
table: "cv_content_audit_log",
columns: contentAuditLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *ContentAuditLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *ContentAuditLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *ContentAuditLogDao) Columns() ContentAuditLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *ContentAuditLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *ContentAuditLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *ContentAuditLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -1,105 +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"
)
// FeedCommentDao is the data access object for the table cv_feed_comment.
type FeedCommentDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedCommentColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedCommentColumns defines and stores column names for the table cv_feed_comment.
type FeedCommentColumns struct {
Id string //
TenantId string //
PostId string //
AuthorId string //
ContentText string //
Status string //
AuditStatus string //
AuditRequestId string //
DeletedBy string //
DeleteReason string //
PublishedAt string //
DeletedAt string //
CreatedAt string //
UpdatedAt string //
}
// feedCommentColumns holds the columns for the table cv_feed_comment.
var feedCommentColumns = FeedCommentColumns{
Id: "id",
TenantId: "tenant_id",
PostId: "post_id",
AuthorId: "author_id",
ContentText: "content_text",
Status: "status",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
DeletedBy: "deleted_by",
DeleteReason: "delete_reason",
PublishedAt: "published_at",
DeletedAt: "deleted_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedCommentDao creates and returns a new DAO object for table data access.
func NewFeedCommentDao(handlers ...gdb.ModelHandler) *FeedCommentDao {
return &FeedCommentDao{
group: "cultivate",
table: "cv_feed_comment",
columns: feedCommentColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedCommentDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedCommentDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedCommentDao) Columns() FeedCommentColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedCommentDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedCommentDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedCommentDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
-105
View File
@@ -1,105 +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"
)
// FeedMediaDao is the data access object for the table cv_feed_media.
type FeedMediaDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedMediaColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedMediaColumns defines and stores column names for the table cv_feed_media.
type FeedMediaColumns struct {
Id string //
TenantId string //
PostId string //
UserId string //
MediaType string //
FileId string //
ThumbnailFileId string //
Width string //
Height string //
SortNo string //
AuditStatus string //
AuditRequestId string //
CreatedAt string //
UpdatedAt string //
}
// feedMediaColumns holds the columns for the table cv_feed_media.
var feedMediaColumns = FeedMediaColumns{
Id: "id",
TenantId: "tenant_id",
PostId: "post_id",
UserId: "user_id",
MediaType: "media_type",
FileId: "file_id",
ThumbnailFileId: "thumbnail_file_id",
Width: "width",
Height: "height",
SortNo: "sort_no",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedMediaDao creates and returns a new DAO object for table data access.
func NewFeedMediaDao(handlers ...gdb.ModelHandler) *FeedMediaDao {
return &FeedMediaDao{
group: "cultivate",
table: "cv_feed_media",
columns: feedMediaColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedMediaDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedMediaDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedMediaDao) Columns() FeedMediaColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedMediaDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedMediaDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedMediaDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
-117
View File
@@ -1,117 +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"
)
// FeedPostDao is the data access object for the table cv_feed_post.
type FeedPostDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedPostColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedPostColumns defines and stores column names for the table cv_feed_post.
type FeedPostColumns struct {
Id string //
TenantId string //
AuthorId string //
ContentText string //
FlowerCode string //
SourceType string //
SourceId string //
Visibility string //
Status string //
AuditStatus string //
AuditRequestId string //
RejectReasonCode string //
LikeCount string //
CommentCount string //
Version string //
PublishedAt string //
DeletedAt string //
TakenDownAt string //
CreatedAt string //
UpdatedAt string //
}
// feedPostColumns holds the columns for the table cv_feed_post.
var feedPostColumns = FeedPostColumns{
Id: "id",
TenantId: "tenant_id",
AuthorId: "author_id",
ContentText: "content_text",
FlowerCode: "flower_code",
SourceType: "source_type",
SourceId: "source_id",
Visibility: "visibility",
Status: "status",
AuditStatus: "audit_status",
AuditRequestId: "audit_request_id",
RejectReasonCode: "reject_reason_code",
LikeCount: "like_count",
CommentCount: "comment_count",
Version: "version",
PublishedAt: "published_at",
DeletedAt: "deleted_at",
TakenDownAt: "taken_down_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedPostDao creates and returns a new DAO object for table data access.
func NewFeedPostDao(handlers ...gdb.ModelHandler) *FeedPostDao {
return &FeedPostDao{
group: "cultivate",
table: "cv_feed_post",
columns: feedPostColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedPostDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedPostDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedPostDao) Columns() FeedPostColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedPostDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedPostDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedPostDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
-109
View File
@@ -1,109 +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"
)
// FeedReportDao is the data access object for the table cv_feed_report.
type FeedReportDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FeedReportColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FeedReportColumns defines and stores column names for the table cv_feed_report.
type FeedReportColumns struct {
Id string //
TenantId string //
ReporterId string //
ReportedUserId string //
ObjectType string //
ObjectId string //
ReasonCode string //
Description string //
EvidenceFileIds string //
SourcePage string //
Status string //
HandlerId string //
HandleResult string //
HandledAt string //
CreatedAt string //
UpdatedAt string //
}
// feedReportColumns holds the columns for the table cv_feed_report.
var feedReportColumns = FeedReportColumns{
Id: "id",
TenantId: "tenant_id",
ReporterId: "reporter_id",
ReportedUserId: "reported_user_id",
ObjectType: "object_type",
ObjectId: "object_id",
ReasonCode: "reason_code",
Description: "description",
EvidenceFileIds: "evidence_file_ids",
SourcePage: "source_page",
Status: "status",
HandlerId: "handler_id",
HandleResult: "handle_result",
HandledAt: "handled_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFeedReportDao creates and returns a new DAO object for table data access.
func NewFeedReportDao(handlers ...gdb.ModelHandler) *FeedReportDao {
return &FeedReportDao{
group: "cultivate",
table: "cv_feed_report",
columns: feedReportColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FeedReportDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FeedReportDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FeedReportDao) Columns() FeedReportColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FeedReportDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FeedReportDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FeedReportDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -1,109 +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"
)
// FlowerCatalogDao is the data access object for the table cv_flower_catalog.
type FlowerCatalogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FlowerCatalogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FlowerCatalogColumns defines and stores column names for the table cv_flower_catalog.
type FlowerCatalogColumns struct {
Id string //
TenantId string //
FlowerCode string //
FlowerName string //
FlowerDesc string //
IconFileId string //
Status string //
ConfigVersion string //
DailyWaterLimit string //
DailyFertilizeLimit string //
ShareConfig string //
SortNo string //
OnlineAt string //
OfflineAt string //
CreatedAt string //
UpdatedAt string //
}
// flowerCatalogColumns holds the columns for the table cv_flower_catalog.
var flowerCatalogColumns = FlowerCatalogColumns{
Id: "id",
TenantId: "tenant_id",
FlowerCode: "flower_code",
FlowerName: "flower_name",
FlowerDesc: "flower_desc",
IconFileId: "icon_file_id",
Status: "status",
ConfigVersion: "config_version",
DailyWaterLimit: "daily_water_limit",
DailyFertilizeLimit: "daily_fertilize_limit",
ShareConfig: "share_config",
SortNo: "sort_no",
OnlineAt: "online_at",
OfflineAt: "offline_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFlowerCatalogDao creates and returns a new DAO object for table data access.
func NewFlowerCatalogDao(handlers ...gdb.ModelHandler) *FlowerCatalogDao {
return &FlowerCatalogDao{
group: "cultivate",
table: "cv_flower_catalog",
columns: flowerCatalogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FlowerCatalogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FlowerCatalogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FlowerCatalogDao) Columns() FlowerCatalogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FlowerCatalogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FlowerCatalogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FlowerCatalogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -1,109 +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"
)
// FlowerStageRuleDao is the data access object for the table cv_flower_stage_rule.
type FlowerStageRuleDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns FlowerStageRuleColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// FlowerStageRuleColumns defines and stores column names for the table cv_flower_stage_rule.
type FlowerStageRuleColumns struct {
Id string //
TenantId string //
FlowerCatalogId string //
Stage string //
NextStage string //
RequiredPoints string //
WaterPoints string //
FertilizePoints string //
AllowedActions string //
ImageFileId string //
AnimationFileId string //
ProgressTextTemplate string //
CompletionText string //
SortNo string //
CreatedAt string //
UpdatedAt string //
}
// flowerStageRuleColumns holds the columns for the table cv_flower_stage_rule.
var flowerStageRuleColumns = FlowerStageRuleColumns{
Id: "id",
TenantId: "tenant_id",
FlowerCatalogId: "flower_catalog_id",
Stage: "stage",
NextStage: "next_stage",
RequiredPoints: "required_points",
WaterPoints: "water_points",
FertilizePoints: "fertilize_points",
AllowedActions: "allowed_actions",
ImageFileId: "image_file_id",
AnimationFileId: "animation_file_id",
ProgressTextTemplate: "progress_text_template",
CompletionText: "completion_text",
SortNo: "sort_no",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewFlowerStageRuleDao creates and returns a new DAO object for table data access.
func NewFlowerStageRuleDao(handlers ...gdb.ModelHandler) *FlowerStageRuleDao {
return &FlowerStageRuleDao{
group: "cultivate",
table: "cv_flower_stage_rule",
columns: flowerStageRuleColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *FlowerStageRuleDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *FlowerStageRuleDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *FlowerStageRuleDao) Columns() FlowerStageRuleColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *FlowerStageRuleDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *FlowerStageRuleDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *FlowerStageRuleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+22 -4
View File
@@ -25,8 +25,17 @@ type ImTenantColumns struct {
Code string //
Name string //
TenantDesc string //
DbName string //
DbDsn 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 //
@@ -40,8 +49,17 @@ var imTenantColumns = ImTenantColumns{
Code: "code",
Name: "name",
TenantDesc: "tenant_desc",
DbName: "db_name",
DbDsn: "db_dsn",
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",
@@ -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})
rules := make([][]string, 0, len(policies))
for _, policy := range policies {
rules = append(rules, []string{policy.Key, policy.Perm, policy.ActRegexp})
}
} else {
rules = append(rules, []string{policy.Key, policy.Permissions, ActionAll})
}
}
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)
}
}
+30 -37
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
}
group := fmt.Sprintf("im_tenant_%d_%x", tenant.Id, hash(link))
if cached, ok := tenantDbGroups.Load(group); ok && cached == link {
db, err = gdb.Instance(group)
return db, tenant, err
return db, tenant, nil
}
if err = gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
return nil, nil, gerror.Wrap(err, "初始化租户业务库连接失败")
// 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 {
return gdb.Instance(group)
}
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,31 +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 == "" {
// 未配置独立业务库的商户回落共享默认库
defaultLink := defaultDbLink(ctx)
if defaultLink == "" {
return "", gerror.New("默认数据库连接未配置,无法回落共享业务库")
}
return normalizeMySQLTimeOptions(defaultLink)
}
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)
}
+17 -3
View File
@@ -65,27 +65,41 @@ func DefaultClient(ctx context.Context) *Client {
}
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, out)
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, out)
return c.doJSON(ctx, http.MethodPost, path, body, tenantID, tenantCode, out)
}
func (c *Client) doJSON(ctx context.Context, method string, path string, body []byte, out any) error {
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)
@@ -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)
}
}
+2 -1
View File
@@ -470,9 +470,10 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err
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租户失败")
+8 -3
View File
@@ -53,9 +53,11 @@ 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()
if _, err = dao.AdminMenu.Ctx(ctx).Where(dao.AdminMenu.Columns().Id, in.Id).Delete(); err != nil {
return
}
return casbin.Refresh(ctx)
}
// VerifyUnique 验证菜单唯一属性
func (s *sAdminMenu) VerifyUnique(ctx context.Context, in *adminin.VerifyUniqueInp) (err error) {
@@ -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 nil
}); err != nil {
return
}
return casbin.Refresh(ctx)
})
}
// List 获取菜单列表
+8 -4
View File
@@ -82,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
@@ -221,15 +221,18 @@ func (s *sAdminRole) Edit(ctx context.Context, in *adminin.RoleEditInp) (err err
// 如果当前角色有子级,更新子级tree关系树
return updateRoleChildrenTree(ctx, in.Id, in.Level, in.Tree)
})
if err != nil {
return
}
return casbin.Refresh(ctx)
}
// 新增
if _, err = dao.AdminRole.Ctx(ctx).Fields(adminin.RoleInsertFields{}).Data(in).OmitEmptyData().Insert(); err != nil {
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) {
@@ -288,9 +291,10 @@ 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 casbin.Refresh(ctx)
}
func (s *sAdminRole) DataScopeSelect() (res form.Selects) {
for k, v := range consts.RoleDataNameMap {
+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)
+87 -644
View File
@@ -2,25 +2,10 @@ package sys
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/library/cultivateadmin"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
@@ -30,709 +15,167 @@ type sSysCultivateContent struct{}
func NewSysCultivateContent() *sSysCultivateContent { return &sSysCultivateContent{} }
func init() { service.RegisterSysCultivateContent(NewSysCultivateContent()) }
func (s *sSysCultivateContent) Dashboard(ctx context.Context, _ *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error) {
tenantID := cultivateTenantID(ctx)
out := new(sysin.CultivateDashboardModel)
db := g.DB("cultivate")
queries := []struct {
target *int64
sql string
args []any
}{
{&out.PlantCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=?", []any{tenantID}},
{&out.GrowingCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='growing'", []any{tenantID}},
{&out.BloomCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='bloomed'", []any{tenantID}},
{&out.HarvestedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='harvested'", []any{tenantID}},
{&out.RemovedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='removed'", []any{tenantID}},
{&out.Active7d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 6 DAY)", []any{tenantID}},
{&out.Active30d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 29 DAY)", []any{tenantID}},
{&out.PostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published'", []any{tenantID}},
{&out.PublicPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='public'", []any{tenantID}},
{&out.FriendsPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='friends'", []any{tenantID}},
{&out.PrivatePostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='private'", []any{tenantID}},
{&out.CommentCount, "SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='published'", []any{tenantID}},
{&out.LikeCount, "SELECT COALESCE(SUM(like_count),0) FROM cv_feed_post WHERE tenant_id=?", []any{tenantID}},
{&out.TakenDownCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='taken_down'", []any{tenantID}},
{&out.PendingAuditCount, "SELECT (SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='reviewing')+(SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='reviewing')", []any{tenantID, tenantID}},
{&out.ApprovedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='published'", []any{tenantID}},
{&out.RejectedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='rejected'", []any{tenantID}},
{&out.PendingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='submitted'", []any{tenantID}},
{&out.ProcessingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='processing'", []any{tenantID}},
{&out.ProcessedReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status IN ('resolved','rejected')", []any{tenantID}},
func (s *sSysCultivateContent) Dashboard(ctx context.Context, in *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error) {
if err := in.Filter(ctx); err != nil {
return nil, err
}
for _, q := range queries {
value, err := db.GetValue(ctx, q.sql, q.args...)
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, gerror.Wrap(err, "获取养成统计失败")
return nil, err
}
*q.target = value.Int64()
result := new(sysin.CultivateDashboardModel)
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/dashboard", nil, result); err != nil {
return nil, err
}
return out, nil
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
}
tenantID := cultivateTenantID(ctx)
if in.ObjectType == "feed_comment" {
columns := dao.FeedComment.Columns()
mod := dao.FeedComment.Ctx(ctx).Where(columns.TenantId, tenantID)
if in.AuthorId != "" {
mod = mod.Where(columns.AuthorId, in.AuthorId)
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
}
if in.Keyword != "" {
mod = mod.WhereLike(columns.ContentText, "%"+strings.TrimSpace(in.Keyword)+"%")
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedComment
err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false)
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, 0, gerror.Wrap(err, "获取评论审核列表失败")
return nil, 0, err
}
list = make([]*sysin.CultivateContentListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateContentListModel{Id: strconv.FormatUint(row.Id, 10), ObjectType: "feed_comment", PostId: strconv.FormatUint(row.PostId, 10), AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, Status: row.Status, AuditStatus: row.AuditStatus, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
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
}
return
if page.Items == nil {
page.Items = []*sysin.CultivateContentListModel{}
}
columns := dao.FeedPost.Columns()
mod := dao.FeedPost.Ctx(ctx).Where(columns.TenantId, tenantID)
if in.AuthorId != "" {
mod = mod.Where(columns.AuthorId, in.AuthorId)
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
}
if in.Keyword != "" {
mod = mod.WhereLike(columns.ContentText, "%"+strings.TrimSpace(in.Keyword)+"%")
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedPost
err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false)
if err != nil {
return nil, 0, gerror.Wrap(err, "获取动态审核列表失败")
}
list = make([]*sysin.CultivateContentListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateContentListModel{Id: strconv.FormatUint(row.Id, 10), ObjectType: "feed_post", AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, FlowerCode: row.FlowerCode, Visibility: row.Visibility, Status: row.Status, AuditStatus: row.AuditStatus, RejectReason: row.RejectReasonCode, LikeCount: row.LikeCount, CommentCount: row.CommentCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return
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
}
tenantID := cultivateTenantID(ctx)
base := &sysin.CultivateContentViewModel{Media: []*sysin.CultivateMediaModel{}, Audits: []*sysin.CultivateAuditModel{}, RelatedReports: []*sysin.CultivateReportListModel{}}
if in.ObjectType == "feed_comment" {
c := dao.FeedComment.Columns()
var row entity.FeedComment
if err := dao.FeedComment.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, err
}
if row.Id == 0 {
return nil, gerror.New("评论不存在")
}
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, PostId: strconv.FormatUint(row.PostId, 10), AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, Status: row.Status, AuditStatus: row.AuditStatus, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
} else {
c := dao.FeedPost.Columns()
var row entity.FeedPost
if err := dao.FeedPost.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
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
}
if row.Id == 0 {
return nil, gerror.New("动态不存在")
}
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, FlowerCode: row.FlowerCode, Visibility: row.Visibility, Status: row.Status, AuditStatus: row.AuditStatus, RejectReason: row.RejectReasonCode, LikeCount: row.LikeCount, CommentCount: row.CommentCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
mc := dao.FeedMedia.Columns()
var media []*entity.FeedMedia
if err := dao.FeedMedia.Ctx(ctx).Where(g.Map{mc.TenantId: tenantID, mc.PostId: in.ObjectId}).OrderAsc(mc.SortNo).Scan(&media); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
for _, m := range media {
base.Media = append(base.Media, &sysin.CultivateMediaModel{Id: strconv.FormatUint(m.Id, 10), FileId: m.FileId, ThumbnailFileId: m.ThumbnailFileId, MediaType: m.MediaType, AuditStatus: m.AuditStatus, SortNo: m.SortNo})
}
}
ac := dao.ContentAuditLog.Columns()
var audits []*entity.ContentAuditLog
if err := dao.ContentAuditLog.Ctx(ctx).Where(g.Map{ac.TenantId: tenantID, ac.ObjectType: in.ObjectType, ac.ObjectId: in.ObjectId}).OrderDesc(ac.Id).Scan(&audits); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
for _, a := range audits {
base.Audits = append(base.Audits, &sysin.CultivateAuditModel{Id: strconv.FormatUint(a.Id, 10), AuditSource: a.AuditSource, StatusBefore: a.StatusBefore, StatusAfter: a.StatusAfter, ResultCode: a.ResultCode, RiskLabels: jsonValue(a.RiskLabels), OperatorId: optionalUint(a.OperatorId), OccurredAt: a.OccurredAt})
}
rc := dao.FeedReport.Columns()
var reports []*entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{rc.TenantId: tenantID, rc.ObjectType: in.ObjectType, rc.ObjectId: in.Id}).OrderDesc(rc.Id).Scan(&reports); err == nil {
for _, r := range reports {
base.RelatedReports = append(base.RelatedReports, reportModel(r))
}
}
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{base.AuthorId})
if p := profiles[base.AuthorId]; p != nil {
base.AuthorNickname = p.Nickname
base.AuthorAvatarFileId = p.AvatarFileId
}
return base, nil
return result, nil
}
func (s *sSysCultivateContent) Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
var authorID uint64
err := dao.FeedPost.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
if in.ObjectType == "feed_comment" {
var row entity.FeedComment
c := dao.FeedComment.Columns()
if e := tx.Model(dao.FeedComment.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
return e
}
authorID = row.AuthorId
return s.reviewComment(txCtx, tx, tenantID, operatorID, in)
}
var row entity.FeedPost
c := dao.FeedPost.Columns()
if e := tx.Model(dao.FeedPost.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
return e
}
authorID = row.AuthorId
return s.reviewPost(txCtx, tx, tenantID, operatorID, in)
})
scope, err := cultivateAdminScope(ctx)
if err != nil {
return err
}
if authorID > 0 {
publishCultivateNotice(ctx, tenantID, authorID, fmt.Sprintf("admin.content.%s.%s.%s", in.ObjectType, in.Id, in.Action), "cultivate.content.reviewed", "内容审核结果", cultivateReviewSummary(in.Action), map[string]any{"type": in.ObjectType, "object_type": in.ObjectType, "object_id": in.Id})
}
return nil
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
}
result := &sysin.CultivateContentBatchReviewModel{Failures: make(map[string]string)}
for _, id := range in.Ids {
item := &sysin.CultivateContentReviewInp{ObjectType: in.ObjectType, Id: id, Action: in.Action, Reason: in.Reason}
if err := item.Filter(ctx); err != nil {
result.FailedCount++
result.Failures[id] = err.Error()
continue
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, err
}
if err := s.Review(ctx, item); err != nil {
result.FailedCount++
result.Failures[id] = err.Error()
continue
}
result.SuccessCount++
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) reviewPost(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, in *sysin.CultivateContentReviewInp) error {
columns := dao.FeedPost.Columns()
var before entity.FeedPost
err := tx.Model(dao.FeedPost.Table()).Where(g.Map{columns.Id: in.ObjectId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("动态不存在")
}
now := time.Now().UTC()
data := g.Map{columns.UpdatedAt: now, columns.Version: gdb.Raw(columns.Version + "+1")}
afterStatus := before.Status
switch in.Action {
case "approve":
if before.Status != "reviewing" && before.Status != "rejected" {
return gerror.New("仅待审核或已拒绝动态可审核通过")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "approved"
data[columns.RejectReasonCode] = ""
if before.PublishedAt == nil {
data[columns.PublishedAt] = now
}
case "reject":
if before.Status != "reviewing" {
return gerror.New("仅待审核动态可拒绝")
}
afterStatus = "rejected"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "rejected"
data[columns.RejectReasonCode] = in.Reason
case "take_down":
if before.Status != "published" {
return gerror.New("仅已发布动态可下架")
}
afterStatus = "taken_down"
data[columns.Status] = afterStatus
data[columns.TakenDownAt] = now
data[columns.RejectReasonCode] = in.Reason
case "restore":
if before.Status != "taken_down" {
return gerror.New("仅已下架动态可恢复")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.TakenDownAt] = nil
data[columns.RejectReasonCode] = ""
}
if _, err = tx.Model(dao.FeedPost.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新动态审核状态失败")
}
if err = writeContentAudit(ctx, tx, tenantID, operatorID, "feed_post", before.Id, before.Status, afterStatus, in.Action, now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content."+in.Action, "feed_post", in.Id, before, data, in.Reason, now)
}
func (s *sSysCultivateContent) reviewComment(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, in *sysin.CultivateContentReviewInp) error {
columns := dao.FeedComment.Columns()
var before entity.FeedComment
err := tx.Model(dao.FeedComment.Table()).Where(g.Map{columns.Id: in.ObjectId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("评论不存在")
}
now := time.Now().UTC()
data := g.Map{columns.UpdatedAt: now}
afterStatus := before.Status
counterDelta := 0
switch in.Action {
case "approve":
if before.Status != "reviewing" && before.Status != "rejected" {
return gerror.New("仅待审核或已拒绝评论可审核通过")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "approved"
data[columns.PublishedAt] = now
counterDelta = 1
case "reject":
if before.Status != "reviewing" {
return gerror.New("仅待审核评论可拒绝")
}
afterStatus = "rejected"
data[columns.Status] = afterStatus
data[columns.AuditStatus] = "rejected"
data[columns.DeleteReason] = in.Reason
case "take_down":
if before.Status != "published" {
return gerror.New("仅已发布评论可下架")
}
afterStatus = "taken_down"
data[columns.Status] = afterStatus
data[columns.DeleteReason] = in.Reason
counterDelta = -1
case "restore":
if before.Status != "taken_down" {
return gerror.New("仅已下架评论可恢复")
}
afterStatus = "published"
data[columns.Status] = afterStatus
data[columns.DeleteReason] = ""
counterDelta = 1
}
if _, err = tx.Model(dao.FeedComment.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新评论审核状态失败")
}
if counterDelta != 0 {
postColumns := dao.FeedPost.Columns()
expression := "`comment_count` + 1"
if counterDelta < 0 {
expression = "GREATEST(`comment_count` - 1, 0)"
}
if _, err = tx.Model(dao.FeedPost.Table()).Where(g.Map{postColumns.Id: before.PostId, postColumns.TenantId: tenantID}).Data(g.Map{postColumns.CommentCount: gdb.Raw(expression), postColumns.UpdatedAt: now}).Update(); err != nil {
return gerror.Wrap(err, "更新动态评论数失败")
}
}
if err = writeContentAudit(ctx, tx, tenantID, operatorID, "feed_comment", before.Id, before.Status, afterStatus, in.Action, now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content."+in.Action, "feed_comment", in.Id, before, data, in.Reason, now)
}
func writeContentAudit(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, objectType string, objectID uint64, before, after, result string, now time.Time) error {
id, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return err
}
columns := dao.ContentAuditLog.Columns()
requestID := fmt.Sprintf("manual-%s-%d-%d", objectType, objectID, id)
_, err = tx.Model(dao.ContentAuditLog.Table()).Data(g.Map{columns.Id: id, columns.TenantId: tenantID, columns.ObjectType: objectType, columns.ObjectId: objectID, columns.AuditRequestId: requestID, columns.AuditSource: "manual", columns.StatusBefore: before, columns.StatusAfter: after, columns.ResultCode: result, columns.RiskLabels: "[]", columns.OperatorId: operatorID, columns.OccurredAt: now, columns.CreatedAt: now}).Insert()
return err
}
func (s *sSysCultivateContent) ReportList(ctx context.Context, in *sysin.CultivateReportListInp) (list []*sysin.CultivateReportListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return nil, 0, err
}
columns := dao.FeedReport.Columns()
mod := dao.FeedReport.Ctx(ctx).Where(columns.TenantId, cultivateTenantID(ctx))
if in.ObjectType != "" {
mod = mod.Where(columns.ObjectType, in.ObjectType)
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, 0, err
}
if in.Status != "" {
mod = mod.Where(columns.Status, in.Status)
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 in.ReporterId != "" {
mod = mod.Where(columns.ReporterId, in.ReporterId)
if page.Items == nil {
page.Items = []*sysin.CultivateReportListModel{}
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.FeedReport
if err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false); err != nil {
return nil, 0, gerror.Wrap(err, "获取举报列表失败")
}
list = make([]*sysin.CultivateReportListModel, 0, len(rows))
for _, row := range rows {
list = append(list, reportModel(row))
}
return
}
func reportModel(row *entity.FeedReport) *sysin.CultivateReportListModel {
return &sysin.CultivateReportListModel{Id: strconv.FormatUint(row.Id, 10), ReporterId: strconv.FormatUint(row.ReporterId, 10), ReportedUserId: strconv.FormatUint(row.ReportedUserId, 10), ObjectType: row.ObjectType, ObjectId: row.ObjectId, ReasonCode: row.ReasonCode, Description: row.Description, EvidenceFileIds: row.EvidenceFileIds, SourcePage: row.SourcePage, Status: row.Status, HandlerId: optionalUint(row.HandlerId), HandleResult: row.HandleResult, HandledAt: row.HandledAt, CreatedAt: row.CreatedAt}
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
}
tenantID := cultivateTenantID(ctx)
c := dao.FeedReport.Columns()
var row entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.Id: in.ReportId, c.TenantId: tenantID}).Scan(&row); err != nil {
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, err
}
if row.Id == 0 {
return nil, gerror.New("举报记录不存在")
result := new(sysin.CultivateReportViewModel)
if err = cultivateadmin.DefaultClient(ctx).Get(ctx, scope, "/reports/"+url.PathEscape(in.Id), nil, result); err != nil {
return nil, err
}
out := &sysin.CultivateReportViewModel{CultivateReportListModel: *reportModel(&row), History: []*sysin.CultivateReportListModel{}}
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{out.ReporterId, out.ReportedUserId})
if p := profiles[out.ReporterId]; p != nil {
out.ReporterNickname = p.Nickname
}
if p := profiles[out.ReportedUserId]; p != nil {
out.ReportedUserNickname = p.Nickname
}
var history []*entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.TenantId: tenantID, c.ReportedUserId: row.ReportedUserId}).OrderDesc(c.Id).Limit(20).Scan(&history); err == nil {
for _, item := range history {
out.History = append(out.History, reportModel(item))
}
}
if row.ObjectType == "feed_post" || row.ObjectType == "feed_comment" {
view, err := s.View(ctx, &sysin.CultivateContentViewInp{ObjectType: row.ObjectType, Id: row.ObjectId, ObjectId: mustUint(row.ObjectId)})
if err == nil {
out.TargetContent = &view.CultivateContentListModel
}
}
return out, nil
return result, nil
}
func (s *sSysCultivateContent) ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
columns := dao.FeedReport.Columns()
var report entity.FeedReport
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).Scan(&report); err != nil {
return err
}
if report.Id == 0 {
return gerror.New("举报记录不存在")
}
if report.Status == "resolved" || report.Status == "rejected" {
return gerror.New("举报已办结,不能重复处理")
}
if in.ContentAction == "take_down" {
if err := validateReportedContentCanTakeDown(ctx, tenantID, &report); err != nil {
return err
}
}
if in.UserAction != "none" && report.ReportedUserId == 0 {
return gerror.New("被举报用户不存在,无法执行用户处置")
}
if in.UserAction != "none" {
if err := governCultivateUser(ctx, tenantID, report.ReportedUserId, in.UserAction, in.Reason); err != nil {
return gerror.Wrap(err, "用户联动处置失败")
}
}
err := dao.FeedReport.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
columns := dao.FeedReport.Columns()
var before entity.FeedReport
err := tx.Model(dao.FeedReport.Table()).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if before.Id == 0 {
return gerror.New("举报记录不存在")
}
if before.Status == "resolved" || before.Status == "rejected" {
return gerror.New("举报已办结,不能重复处理")
}
now := time.Now().UTC()
status := in.Action
if status == "resolve" {
status = "resolved"
}
data := g.Map{columns.Status: status, columns.HandlerId: operatorID, columns.HandleResult: in.HandleResult, columns.UpdatedAt: now}
if status == "resolved" || status == "rejected" {
data[columns.HandledAt] = now
}
if in.ContentAction == "take_down" {
if err = takeDownReportedContent(txCtx, tx, tenantID, operatorID, &before, in.Reason, now); err != nil {
return err
}
}
if _, err = tx.Model(dao.FeedReport.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新举报处理状态失败")
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, "report."+in.Action, "feed_report", in.Id, before, data, in.HandleResult, now)
})
scope, err := cultivateAdminScope(ctx)
if err != nil {
return err
}
if in.Action == "resolve" || in.Action == "reject" {
summary := "你的举报已处理,感谢你帮助维护社区环境"
if in.Action == "reject" {
summary = "你的举报已完成核查,暂未发现违规"
}
publishCultivateNotice(ctx, tenantID, report.ReporterId, "admin.report."+in.Id, "cultivate.report.handled", "举报处理结果", summary, map[string]any{"type": "report", "report_id": in.Id})
}
if in.ContentAction != "none" || in.UserAction != "none" {
publishCultivateNotice(ctx, tenantID, report.ReportedUserId, "admin.report.target."+in.Id, "cultivate.governance.applied", "社区治理通知", "你的内容或账号已根据社区规则进行处置", map[string]any{"type": "report", "report_id": in.Id})
}
return nil
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) {
columns := dao.AdminOperationLog.Columns()
mod := dao.AdminOperationLog.Ctx(ctx).Where(columns.TenantId, cultivateTenantID(ctx))
if in.OperatorId != "" {
mod = mod.Where(columns.OperatorId, in.OperatorId)
if err = in.Filter(ctx); err != nil {
return nil, 0, err
}
if in.OperationType != "" {
mod = mod.WhereLike(columns.OperationType, "%"+strings.TrimSpace(in.OperationType)+"%")
}
if in.TargetType != "" {
mod = mod.Where(columns.TargetType, in.TargetType)
}
if in.TargetId != "" {
mod = mod.Where(columns.TargetId, strings.TrimSpace(in.TargetId))
}
if in.RequestId != "" {
mod = mod.Where(columns.RequestId, strings.TrimSpace(in.RequestId))
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
var rows []*entity.AdminOperationLog
if err = mod.Page(in.Page, in.PerPage).OrderDesc(columns.Id).ScanAndCount(&rows, &totalCount, false); err != nil {
return nil, 0, gerror.Wrap(err, "获取操作日志失败")
}
list = make([]*sysin.CultivateOperationLogListModel, 0, len(rows))
for _, row := range rows {
list = append(list, &sysin.CultivateOperationLogListModel{Id: strconv.FormatUint(row.Id, 10), OperatorId: strconv.FormatUint(row.OperatorId, 10), OperationType: row.OperationType, TargetType: row.TargetType, TargetId: row.TargetId, RequestId: row.RequestId, BeforeSnapshot: jsonValue(row.BeforeSnapshot), AfterSnapshot: jsonValue(row.AfterSnapshot), Reason: row.Reason, CreatedAt: row.CreatedAt})
}
return
}
func optionalUint(value uint64) string {
if value == 0 {
return ""
}
return strconv.FormatUint(value, 10)
}
func jsonValue(value *gjson.Json) any {
if value == nil {
return nil
}
return value
}
type cultivateProfile struct {
UserId int64 `json:"user_id"`
Nickname string `json:"nickname"`
AvatarFileId string `json:"avatar_file_id"`
}
func cultivateUserProfiles(ctx context.Context, tenantID int64, userIDs []string) (map[string]*cultivateProfile, error) {
result := make(map[string]*cultivateProfile)
ids := make([]int64, 0, len(userIDs))
for _, value := range userIDs {
id, err := strconv.ParseInt(value, 10, 64)
if err == nil && id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return result, nil
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, err
return nil, 0, err
}
var out struct {
Items []*cultivateProfile `json:"items"`
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
}
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/users/batch"
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_ids": ids}, &out); err != nil {
return nil, err
if page.Items == nil {
page.Items = []*sysin.CultivateOperationLogListModel{}
}
for _, item := range out.Items {
result[strconv.FormatInt(item.UserId, 10)] = item
}
return result, nil
}
func publishCultivateNotice(ctx context.Context, tenantID int64, targetUserID uint64, eventID, eventType, title, summary string, action map[string]any) {
if targetUserID == 0 {
return
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err == nil {
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/events/publish"
payload := map[string]any{"event_id": eventID, "event_type": eventType, "actor_id": contexts.GetUserId(ctx), "target_user_id": targetUserID, "title": title, "summary": summary, "action": action}
err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
}
if err != nil {
g.Log().Warningf(ctx, "养成通知投递失败: %v", err)
}
}
func cultivateReviewSummary(action string) string {
switch action {
case "approve":
return "你的内容已审核通过"
case "reject":
return "你的内容未通过审核,请修改后重试"
case "take_down":
return "你的内容已根据社区规则下架"
case "restore":
return "你的内容已恢复展示"
default:
return "你的内容审核状态已更新"
}
}
func mustUint(value string) uint64 {
id, _ := strconv.ParseUint(value, 10, 64)
return id
}
func governCultivateUser(ctx context.Context, tenantID int64, userID uint64, action, reason string) error {
tenant, err := resolveImAdminTenant(ctx, tenantID)
if err != nil {
return err
}
eventType := consts.ImAdminCommandUserFreeze
if action == "ban" {
eventType = consts.ImAdminCommandUserBan
}
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatUint(userID, 10) + "/action"
payload := map[string]any{"event_type": eventType, "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), "reason": reason}
return imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
}
func takeDownReportedContent(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, report *entity.FeedReport, reason string, now time.Time) error {
id := mustUint(report.ObjectId)
if id == 0 {
return gerror.New("举报对象ID不正确")
}
if report.ObjectType == "feed_post" {
columns := dao.FeedPost.Columns()
var before entity.FeedPost
if err := tx.Model(dao.FeedPost.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
return err
}
if before.Id == 0 {
return gerror.New("被举报动态不存在")
}
if before.Status != "published" {
return gerror.New("仅已发布动态可联动下架")
}
data := g.Map{columns.Status: "taken_down", columns.TakenDownAt: now, columns.RejectReasonCode: reason, columns.UpdatedAt: now, columns.Version: gdb.Raw(columns.Version + "+1")}
if _, err := tx.Model(dao.FeedPost.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
return err
}
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
}
if report.ObjectType == "feed_comment" {
columns := dao.FeedComment.Columns()
var before entity.FeedComment
if err := tx.Model(dao.FeedComment.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
return err
}
if before.Id == 0 {
return gerror.New("被举报评论不存在")
}
if before.Status != "published" {
return gerror.New("仅已发布评论可联动下架")
}
data := g.Map{columns.Status: "taken_down", columns.DeleteReason: reason, columns.UpdatedAt: now}
if _, err := tx.Model(dao.FeedComment.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
return err
}
postColumns := dao.FeedPost.Columns()
if _, err := tx.Model(dao.FeedPost.Table()).Where(g.Map{postColumns.Id: before.PostId, postColumns.TenantId: tenantID}).Data(g.Map{postColumns.CommentCount: gdb.Raw("GREATEST(`comment_count` - 1, 0)"), postColumns.UpdatedAt: now}).Update(); err != nil {
return err
}
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
return err
}
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
}
return gerror.New("该举报对象不支持内容联动下架")
}
func validateReportedContentCanTakeDown(ctx context.Context, tenantID int64, report *entity.FeedReport) error {
id := mustUint(report.ObjectId)
if id == 0 {
return gerror.New("举报对象ID不正确")
}
switch report.ObjectType {
case "feed_post":
columns := dao.FeedPost.Columns()
value, err := dao.FeedPost.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
if err != nil {
return err
}
if value.String() != "published" {
return gerror.New("仅已发布动态可联动下架")
}
case "feed_comment":
columns := dao.FeedComment.Columns()
value, err := dao.FeedComment.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
if err != nil {
return err
}
if value.String() != "published" {
return gerror.New("仅已发布评论可联动下架")
}
default:
return gerror.New("该举报对象不支持内容联动下架")
}
return nil
return page.Items, page.Total, nil
}
+218 -198
View File
@@ -3,24 +3,20 @@ package sys
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imadmin"
"hotgo/internal/library/imtenant"
"hotgo/internal/library/location"
"hotgo/internal/model/entity"
"hotgo/internal/library/cultivateadmin"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
@@ -30,6 +26,40 @@ 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 {
@@ -38,222 +68,212 @@ func cultivateTenantID(ctx context.Context) int64 {
return id
}
func (s *sSysCultivateFlower) List(ctx context.Context, in *sysin.CultivateFlowerListInp) (list []*sysin.CultivateFlowerListModel, totalCount int, err error) {
c := dao.FlowerCatalog.Columns()
mod := dao.FlowerCatalog.Ctx(ctx).Fields("cv_flower_catalog.*, COUNT(cv_flower_stage_rule.id) AS stage_count").LeftJoin(dao.FlowerStageRule.Table(), "cv_flower_stage_rule.flower_catalog_id=cv_flower_catalog.id AND cv_flower_stage_rule.tenant_id=cv_flower_catalog.tenant_id").Where("cv_flower_catalog."+c.TenantId, cultivateTenantID(ctx)).Group("cv_flower_catalog." + c.Id)
if in.FlowerCode != "" {
mod = mod.WhereLike("cv_flower_catalog."+c.FlowerCode, "%"+strings.TrimSpace(in.FlowerCode)+"%")
}
if in.FlowerName != "" {
mod = mod.WhereLike("cv_flower_catalog."+c.FlowerName, "%"+strings.TrimSpace(in.FlowerName)+"%")
}
if in.Status != "" {
mod = mod.Where("cv_flower_catalog."+c.Status, in.Status)
}
if in.ConfigVersion != "" {
mod = mod.Where("cv_flower_catalog."+c.ConfigVersion, in.ConfigVersion)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("cv_flower_catalog."+c.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("cv_flower_catalog." + c.SortNo).OrderDesc("cv_flower_catalog." + c.Id)
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取花种列表失败")
}
return
}
func (s *sSysCultivateFlower) View(ctx context.Context, in *sysin.CultivateFlowerViewInp) (res *sysin.CultivateFlowerViewModel, err error) {
c := dao.FlowerCatalog.Columns()
var catalog entity.FlowerCatalog
if err = dao.FlowerCatalog.Ctx(ctx).Where(g.Map{c.Id: in.Id, c.TenantId: cultivateTenantID(ctx)}).Scan(&catalog); err != nil {
return
}
if catalog.Id == 0 {
return nil, gerror.New("花种不存在")
}
res = &sysin.CultivateFlowerViewModel{CultivateFlowerEditInp: sysin.CultivateFlowerEditInp{Id: catalog.Id, FlowerCode: catalog.FlowerCode, FlowerName: catalog.FlowerName, FlowerDesc: catalog.FlowerDesc, IconFileId: catalog.IconFileId, Status: catalog.Status, DailyWaterLimit: catalog.DailyWaterLimit, DailyFertilizeLimit: catalog.DailyFertilizeLimit, ShareConfig: catalog.ShareConfig, SortNo: catalog.SortNo}, ConfigVersion: catalog.ConfigVersion, CreatedAt: catalog.CreatedAt, UpdatedAt: catalog.UpdatedAt}
rc := dao.FlowerStageRule.Columns()
var rules []*entity.FlowerStageRule
if err = dao.FlowerStageRule.Ctx(ctx).Where(g.Map{rc.TenantId: cultivateTenantID(ctx), rc.FlowerCatalogId: catalog.Id}).OrderAsc(rc.SortNo).OrderAsc(rc.Id).Scan(&rules); err != nil {
return
}
for _, rule := range rules {
actions := []string{}
if rule.AllowedActions != nil {
_ = json.Unmarshal(rule.AllowedActions.MustToJson(), &actions)
}
res.StageRules = append(res.StageRules, &sysin.CultivateStageRuleEditInp{Id: rule.Id, Stage: rule.Stage, NextStage: rule.NextStage, RequiredPoints: rule.RequiredPoints, WaterPoints: rule.WaterPoints, FertilizePoints: rule.FertilizePoints, AllowedActions: actions, ImageFileId: rule.ImageFileId, AnimationFileId: rule.AnimationFileId, ProgressTextTemplate: rule.ProgressTextTemplate, CompletionText: rule.CompletionText, SortNo: rule.SortNo})
}
return
}
func (s *sSysCultivateFlower) Edit(ctx context.Context, in *sysin.CultivateFlowerEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return err
}
tenantID := cultivateTenantID(ctx)
if err = validateCultivateAssets(ctx, tenantID, in); err != nil {
return err
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)
now := time.Now().UTC()
version := "cat_" + now.Format("20060102_150405")
return dao.FlowerCatalog.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
c := dao.FlowerCatalog.Columns()
var before entity.FlowerCatalog
if in.Id > 0 {
if e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).LockUpdate().Scan(&before); e != nil {
return e
if operatorID <= 0 {
return cultivateadmin.Scope{}, gerror.New("当前管理员身份无效")
}
if before.Id == 0 {
return gerror.New("花种不存在")
}
if before.FlowerCode != in.FlowerCode {
return gerror.New("花种编码创建后不可修改")
}
}
shareJSON := "{}"
if in.ShareConfig != nil {
shareJSON = in.ShareConfig.MustToJsonString()
}
data := g.Map{c.FlowerName: in.FlowerName, c.FlowerDesc: in.FlowerDesc, c.IconFileId: in.IconFileId, c.Status: in.Status, c.ConfigVersion: version, c.DailyWaterLimit: in.DailyWaterLimit, c.DailyFertilizeLimit: in.DailyFertilizeLimit, c.ShareConfig: shareJSON, c.SortNo: in.SortNo, c.UpdatedAt: now}
if in.Status == "online" {
data[c.OnlineAt] = now
data[c.OfflineAt] = nil
} else if in.Status == "offline" {
data[c.OfflineAt] = now
}
if in.Id == 0 {
id, e := imtenant.NextSnowflakeID(txCtx)
if e != nil {
return e
}
in.Id = uint64(id)
data[c.Id] = id
data[c.TenantId] = tenantID
data[c.FlowerCode] = in.FlowerCode
data[c.CreatedAt] = now
if _, e = tx.Model(dao.FlowerCatalog.Table()).Data(data).Insert(); e != nil {
return gerror.Wrap(e, "新增花种失败")
}
} else {
if _, e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).Data(data).Update(); e != nil {
return gerror.Wrap(e, "修改花种失败")
}
}
rc := dao.FlowerStageRule.Columns()
if _, e := tx.Model(dao.FlowerStageRule.Table()).Where(g.Map{rc.TenantId: tenantID, rc.FlowerCatalogId: in.Id}).Delete(); e != nil {
return e
}
for _, rule := range in.StageRules {
id, e := imtenant.NextSnowflakeID(txCtx)
if e != nil {
return e
}
actions, _ := json.Marshal(rule.AllowedActions)
_, e = tx.Model(dao.FlowerStageRule.Table()).Data(g.Map{rc.Id: id, rc.TenantId: tenantID, rc.FlowerCatalogId: in.Id, rc.Stage: rule.Stage, rc.NextStage: rule.NextStage, rc.RequiredPoints: rule.RequiredPoints, rc.WaterPoints: rule.WaterPoints, rc.FertilizePoints: rule.FertilizePoints, rc.AllowedActions: string(actions), rc.ImageFileId: rule.ImageFileId, rc.AnimationFileId: rule.AnimationFileId, rc.ProgressTextTemplate: rule.ProgressTextTemplate, rc.CompletionText: rule.CompletionText, rc.SortNo: rule.SortNo, rc.CreatedAt: now, rc.UpdatedAt: now}).Insert()
if e != nil {
return gerror.Wrap(e, "保存成长阶段失败")
}
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, map[bool]string{true: "flower.edit", false: "flower.create"}[before.Id > 0], "flower", fmt.Sprint(in.Id), before, in, "", now)
})
return cultivateadmin.Scope{
TenantID: tenant.Id, TenantCode: tenant.Code, OperatorID: operatorID,
RoleKey: contexts.GetRoleKey(ctx), RequestID: gctx.CtxId(ctx),
}, nil
}
func validateCultivateAssets(ctx context.Context, tenantID int64, in *sysin.CultivateFlowerEditInp) error {
seen := make(map[string]struct{})
fileIDs := make([]string, 0, 1+len(in.StageRules)*2)
add := func(fileID string) {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return
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
}
if _, ok := seen[fileID]; ok {
return
scope, err := cultivateAdminScope(ctx)
if err != nil {
return nil, 0, err
}
seen[fileID] = struct{}{}
fileIDs = append(fileIDs, fileID)
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
}
add(in.IconFileId)
for _, rule := range in.StageRules {
add(rule.ImageFileId)
add(rule.AnimationFileId)
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
}
if len(fileIDs) == 0 {
return nil
list = append(list, item)
}
tenant, err := resolveImAdminTenant(ctx, tenantID)
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
}
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/files/validate"
for offset := 0; offset < len(fileIDs); offset += 20 {
end := offset + 20
if end > len(fileIDs) {
end = len(fileIDs)
payload := flowerPayload(in)
var response struct {
ID string `json:"id"`
}
var out struct {
Items []struct {
FileId string `json:"file_id"`
ReviewStatus string `json:"review_status"`
} `json:"items"`
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 = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_id": 0, "file_ids": fileIDs[offset:end]}, &out); err != nil {
return gerror.Wrap(err, "校验花种素材失败")
}
for _, item := range out.Items {
if item.ReviewStatus == "risky" {
return gerror.Newf("素材 %s 审核未通过", item.FileId)
}
if item.ReviewStatus == "pending" {
return gerror.Newf("素材 %s 尚在审核中", item.FileId)
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 {
tenantID := cultivateTenantID(ctx)
operatorID := contexts.GetUserId(ctx)
now := time.Now().UTC()
return dao.FlowerCatalog.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
c := dao.FlowerCatalog.Columns()
var before entity.FlowerCatalog
if e := tx.Model(dao.FlowerCatalog.Table()).Where(g.Map{c.Id: in.Id, c.TenantId: tenantID}).LockUpdate().Scan(&before); e != nil {
return e
if err := in.Filter(ctx); err != nil {
return err
}
if before.Id == 0 {
return gerror.New("花种不存在")
scope, err := cultivateAdminScope(ctx)
if err != nil {
return err
}
data := g.Map{c.Status: in.Status, c.ConfigVersion: "cat_" + now.Format("20060102_150405"), c.UpdatedAt: now}
if in.Status == "online" {
data[c.OnlineAt] = now
data[c.OfflineAt] = nil
} else if in.Status == "offline" {
data[c.OfflineAt] = now
}
if _, e := tx.Model(dao.FlowerCatalog.Table()).Where(c.Id, in.Id).Data(data).Update(); e != nil {
return e
}
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, "flower.status", "flower", fmt.Sprint(in.Id), before, data, in.Reason, now)
})
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 writeCultivateOperation(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, operation, targetType, targetID string, before, after any, reason string, now time.Time) error {
id, e := imtenant.NextSnowflakeID(ctx)
if e != nil {
return e
func flowerPayload(in *sysin.CultivateFlowerEditInp) map[string]any {
shareConfig := map[string]any{}
if in.ShareConfig != nil {
_ = json.Unmarshal([]byte(in.ShareConfig.MustToJsonString()), &shareConfig)
}
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
ipAddress, userAgent := "", ""
if request := ghttp.RequestFromCtx(ctx); request != nil {
ipAddress = location.GetClientIp(request)
userAgent = request.UserAgent()
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))
}
c := dao.AdminOperationLog.Columns()
_, e = tx.Model(dao.AdminOperationLog.Table()).Data(g.Map{c.Id: id, c.TenantId: tenantID, c.OperatorId: operatorID, c.OperationType: operation, c.TargetType: targetType, c.TargetId: targetID, c.RequestId: gctx.CtxId(ctx), c.BeforeSnapshot: gjson.New(beforeJSON).MustToJsonString(), c.AfterSnapshot: gjson.New(afterJSON).MustToJsonString(), c.Reason: reason, c.IpAddress: ipAddress, c.UserAgent: userAgent, c.CreatedAt: now}).Insert()
return e
}
+30 -11
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,29 +130,43 @@ 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 _, 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
}
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 {
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
}
@@ -26,7 +26,7 @@ func (s *sSysImFeedbackConfig) Get(ctx context.Context, in *sysin.ImFeedbackConf
return nil, err
}
res = &sysin.ImFeedbackConfigModel{}
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/feedback-config", nil, res); err != nil {
if err = imworkbench.DefaultClient(ctx).GetForTenant(ctx, "/internal/admin/workbench/feedback-config", nil, tenant.Id, tenant.Code, res); err != nil {
return nil, err
}
if res != nil {
@@ -52,13 +52,13 @@ 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 = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/feedback-config", 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,
}, nil); err != nil {
}, 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})
+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
}
@@ -26,7 +26,7 @@ func (s *sSysImMailboxConfig) Get(ctx context.Context, in *sysin.ImMailboxConfig
return nil, err
}
res = &sysin.ImMailboxConfigModel{}
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/mailbox-config", nil, res); err != nil {
if err = imworkbench.DefaultClient(ctx).GetForTenant(ctx, "/internal/admin/workbench/mailbox-config", nil, tenant.Id, tenant.Code, res); err != nil {
return nil, err
}
if res != nil {
@@ -52,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 = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/mailbox-config", 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,
@@ -60,7 +60,7 @@ func (s *sSysImMailboxConfig) Update(ctx context.Context, in *sysin.ImMailboxCon
"attachment_policy": attachmentPolicy,
"notice_title": in.NoticeTitle,
"notice_content": in.NoticeContent,
}, nil); err != nil {
}, 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})
@@ -40,10 +40,9 @@ func (s *sSysImMessageEncryption) List(ctx context.Context, in *sysin.ImMessageE
}
db := imtenant.RegistryDB()
mod := db.Model(dao.ImTenant.Table()+" t").Safe().Ctx(ctx).
LeftJoin(dao.ImTenantConfig.Table()+" c", "c.tenant_id=t.id").
Fields(
"t.id tenant_id", "t.code tenant_code", "t.name tenant_name",
"COALESCE(c."+imTenantMessageEncryptionColumn+",0) message_encryption_enabled",
"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",
@@ -56,7 +55,7 @@ func (s *sSysImMessageEncryption) List(ctx context.Context, in *sysin.ImMessageE
mod = mod.Where("t.code LIKE ? OR t.name LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
if in.Enabled != nil {
mod = mod.Where("COALESCE(c."+imTenantMessageEncryptionColumn+",0)=?", *in.Enabled)
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, "获取商户消息加密配置失败")
@@ -185,16 +184,16 @@ func (s *sSysImMessageEncryption) Switch(ctx context.Context, in *sysin.ImMessag
return gerror.New("启用消息加密前必须先初始化有效密钥")
}
}
result, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).
Data(g.Map{imTenantMessageEncryptionColumn: in.Enabled, dao.ImTenantConfig.Columns().UpdatedBy: contexts.GetUserId(ctx)}).Update()
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", "im_tenant_config", tenant.Id, "success", g.Map{"enabled": in.Enabled})
_ = 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)
}
@@ -203,8 +202,8 @@ func (s *sSysImMessageEncryption) GetKey(ctx context.Context, in *sysin.ImMessag
if err != nil {
return nil, err
}
enabledValue, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields(imTenantMessageEncryptionColumn).Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Value()
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, "获取商户消息加密开关失败")
}
@@ -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
+102 -102
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,6 +114,8 @@ 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
@@ -96,55 +124,52 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
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()
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
}
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
}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: normalizedModerationPolicy(in.SensitiveWordPolicy, tenantCode), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
if in.Id > 0 && in.LoginRateLimit == nil {
// 未提交登录失败策略时保留原值(登录失败锁定已迁移至独立配置入口,避免保存草稿清空配置)
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 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 {
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 {
@@ -153,12 +178,26 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
return insertErr
}
}
if tenantID > 0 {
// 上游已将商户配置并入 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 0, gerror.Wrap(err, "保存安全策略草稿失败")
return 0, gerror.Wrap(err, "保存安全策略失败")
}
_ = 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})
}
_ = 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
}
@@ -176,51 +215,6 @@ func normalizedModerationPolicy(value *gjson.Json, _ string) string {
return gjson.New(policy).String()
}
func (s *sSysImSecurityPolicy) Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) error {
if err := in.Filter(ctx); err != nil {
return err
}
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
}
}
return nil
})
if err != nil {
return 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())
}
func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImSecurityPolicyRestoreInp) (int64, error) {
if err := in.Filter(ctx); err != nil {
return 0, err
@@ -229,27 +223,15 @@ 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})
}
@@ -326,16 +308,34 @@ func (s *sSysImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, in *s
return gerror.Wrap(err, "保存登录失败策略配置失败")
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", policyID, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version)
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})
}
// 租户尚无已发布策略:创建默认策略草稿并直接发布(Publish 内部已发送策略变更事件)
id, saveErr := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "默认安全策略", LoginRateLimit: gjson.New(value), GlobalFriendEnabled: 1, MessageCurfewEnabled: 0})
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
}
if err = s.Publish(ctx, &sysin.ImSecurityPolicyPublishInp{Id: id, Reason: "登录失败策略首次配置"}); err != nil {
return err
}
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", id, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
return nil
}
+200 -205
View File
@@ -23,32 +23,14 @@ import (
"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{}
const (
tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled"
tenantConfigLocalModerationEnabled = "local_moderation_enabled"
tenantConfigMessageEncryptionEnabled = "message_encryption_enabled"
tenantConfigRealnameReviewPolicy = "realname_review_policy"
)
// imManageRoleKey 商户级IM管理员角色标识
const imManageRoleKey = "imManage"
type imTenantConfigModeration struct {
Id int64 `json:"id" orm:"id"`
TenantId int64 `json:"tenant_id" orm:"tenant_id"`
MaxUsers uint64 `json:"max_users" orm:"max_users"`
MaxGroups uint64 `json:"max_groups" orm:"max_groups"`
MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"`
AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"`
MessageEncryptionEnabled int `json:"message_encryption_enabled" orm:"message_encryption_enabled"`
RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"`
}
func NewSysImTenant() *sSysImTenant {
return &sSysImTenant{}
}
@@ -62,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 {
@@ -77,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,
@@ -102,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 != "" && isSuperTenantAdmin(ctx) {
mod = mod.WhereLike(dao.ImTenant.Columns().DbName, "%"+in.DbName+"%")
}
if in.State != nil {
mod = mod.Where(dao.ImTenant.Columns().State, *in.State)
}
@@ -112,72 +93,25 @@ 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
}
// 业务库名仅超管可见,非超管脱敏返回
if !isSuperTenantAdmin(ctx) {
for _, item := range list {
item.DbName = ""
}
}
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)
}
var configs []*imTenantConfigModeration
if err = s.configModel(ctx).
WhereIn(dao.ImTenantConfig.Columns().TenantId, ids).
Scan(&configs); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
configMap := make(map[int64]*imTenantConfigModeration, 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
}
super := isSuperTenantAdmin(ctx)
for _, item := range list {
if conf, ok := configMap[item.Id]; ok {
item.MaxUsers = conf.MaxUsers
item.MaxGroups = conf.MaxGroups
item.MaxGroupMembers = conf.MaxGroupMembers
item.AliyunModerationEnabled = conf.AliyunModerationEnabled
item.LocalModerationEnabled = conf.LocalModerationEnabled
item.MessageEncryptionEnabled = conf.MessageEncryptionEnabled
item.RealnameReviewPolicy = conf.RealnameReviewPolicy
}
item.DbDsnConfigured = super && dsnMap[item.Id] != ""
}
return
}
@@ -222,13 +156,19 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp
super := isSuperTenantAdmin(ctx)
isCreate := in.Id == 0
tenantID := in.Id
// 业务库名与独立连接串仅超管可维护,非超管提交一律强制忽略
if !super {
in.DbName = ""
in.DbDsn = ""
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
@@ -241,31 +181,41 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp
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 super {
data[dao.ImTenant.Columns().DbName] = in.DbName
data[dao.ImTenant.Columns().DbDsn] = in.DbDsn
}
if _, err = s.Model(ctx).
WherePri(in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, in, userId)
return nil
}
in.Id, err = nextTenantID(tx)
if err != nil {
return err
}
in.CreatedBy = userId
in.UpdatedBy = userId
id, err := s.Model(ctx, &handler.Option{FilterAuth: false}).
_, 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租户失败,请稍后重试!")
}
tenantID = id
in.Id = id
return s.saveConfig(ctx, in, userId)
tenantID = in.Id
// 新建商户时复制安全策略模板为本商户初始策略(保存即生效模型)
if err = copyDefaultPolicyForTenant(ctx, tx, in.Id, userId); err != nil {
return err
}
return nil
})
if err != nil {
return err
@@ -277,13 +227,25 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp
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
}
@@ -396,12 +358,91 @@ func (s *sSysImTenant) rollbackTenantCreation(ctx context.Context, tenantId int6
WherePri(tenantId).Delete(); err != nil {
return
}
_, err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).Delete()
// 清理建户时复制的安全策略与宵禁规则,避免残留孤儿数据(商户未建成,硬删不留软删垃圾)
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) {
mod := s.Model(ctx).Where(dao.ImTenant.Columns().Code, code)
if id > 0 {
@@ -417,10 +458,10 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin
return
}
func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp, userId int64) (err error) {
if in.MessageEncryptionEnabled == 1 {
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", in.Id).
Where("tenant_id", tenantId).
Where("status", consts.ImMessageKeyStatusActive).
Count()
if queryErr != nil {
@@ -430,61 +471,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp
return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥")
}
}
var conf *entity.ImTenantConfig
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
data := g.Map{
dao.ImTenantConfig.Columns().MaxUsers: in.MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups: in.MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers,
tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled,
tenantConfigLocalModerationEnabled: in.LocalModerationEnabled,
tenantConfigMessageEncryptionEnabled: in.MessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy,
}
if conf != nil && conf.Id > 0 {
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
if _, err = s.configModel(ctx).
Fields(
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
tenantConfigMessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy,
dao.ImTenantConfig.Columns().UpdatedBy,
).
WherePri(conf.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户配置失败,请稍后重试!")
}
return
}
data[dao.ImTenantConfig.Columns().TenantId] = in.Id
data[dao.ImTenantConfig.Columns().CreatedBy] = userId
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
if _, err = s.configModel(ctx).
Fields(
dao.ImTenantConfig.Columns().TenantId,
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
tenantConfigMessageEncryptionEnabled,
tenantConfigRealnameReviewPolicy,
dao.ImTenantConfig.Columns().CreatedBy,
dao.ImTenantConfig.Columns().UpdatedBy,
).
Data(data).Insert(); err != nil {
return gerror.Wrap(err, "新增IM租户配置失败,请稍后重试!")
}
return
return nil
}
// View 获取IM租户指定信息
@@ -492,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
@@ -500,23 +490,6 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
return
}
var conf *imTenantConfigModeration
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
err = gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
return
}
if conf != nil {
res.MaxUsers = conf.MaxUsers
res.MaxGroups = conf.MaxGroups
res.MaxGroupMembers = conf.MaxGroupMembers
res.AliyunModerationEnabled = conf.AliyunModerationEnabled
res.LocalModerationEnabled = conf.LocalModerationEnabled
res.MessageEncryptionEnabled = conf.MessageEncryptionEnabled
res.RealnameReviewPolicy = conf.RealnameReviewPolicy
}
res.DbDsnConfigured = res.DbDsn != ""
// 查询商户自动创建的管理员账号(编辑弹窗展示)
roleVal, roleErr := dao.AdminRole.Ctx(ctx).
Fields(dao.AdminRole.Columns().Id).
@@ -536,12 +509,6 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
res.AdminUsername = userVal.String()
}
}
// 业务库名与独立连接串仅超管可见,非超管全部脱敏
if !isSuperTenantAdmin(ctx) {
res.DbName = ""
res.DbDsn = ""
res.DbDsnConfigured = false
}
return
}
@@ -550,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),
@@ -564,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 {
@@ -577,15 +555,8 @@ func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (r
Id: tenant.Id,
Code: tenant.Code,
Name: tenant.Name,
DbName: tenant.DbName,
DbDsnConfigured: tenant.DbDsn != "",
Warnings: []string{},
}
// 业务库名与连接串信息仅超管可见,非超管全部脱敏
if !isSuperTenantAdmin(ctx) {
res.DbName = ""
res.DbDsnConfigured = false
}
db, _, dbErr := imtenant.DB(ctx, tenant.Id)
if dbErr != nil {
@@ -601,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
@@ -687,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
}
+26 -26
View File
@@ -861,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
}
@@ -874,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
}
@@ -892,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
})
@@ -920,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
}
@@ -928,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
}
@@ -942,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
})
@@ -977,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
}
@@ -992,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
}
@@ -1008,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
})
+4 -4
View File
@@ -40,7 +40,7 @@ func (s *sSysImWorkbench) List(ctx context.Context, in *sysin.ImWorkbenchAppList
values.Set("enabled", intToString(*in.Enabled))
}
var page imworkbench.Page[sysin.ImWorkbenchAppModel]
if err = imworkbench.DefaultClient(ctx).Get(ctx, "/internal/admin/workbench/apps", values, &page); err != nil {
if err = imworkbench.DefaultClient(ctx).GetForTenant(ctx, "/internal/admin/workbench/apps", values, tenant.Id, tenant.Code, &page); err != nil {
return nil, 0, err
}
list = make([]*sysin.ImWorkbenchAppModel, 0, len(page.Items))
@@ -78,7 +78,7 @@ func (s *sSysImWorkbench) Edit(ctx context.Context, in *sysin.ImWorkbenchAppEdit
action = "im_workbench_app_update"
}
var res sysin.ImWorkbenchAppModel
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps", data, &res); err != nil {
if err = imworkbench.DefaultClient(ctx).PostForTenant(ctx, "/internal/admin/workbench/apps", data, tenant.Id, tenant.Code, &res); err != nil {
return err
}
if targetId <= 0 {
@@ -98,7 +98,7 @@ func (s *sSysImWorkbench) Delete(ctx context.Context, in *sysin.ImWorkbenchAppDe
if err != nil {
return
}
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps/disable", g.Map{"id": in.Id, "code": in.Code}, nil); err != nil {
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})
@@ -120,7 +120,7 @@ func (s *sSysImWorkbench) Reorder(ctx context.Context, in *sysin.ImWorkbenchAppR
"sort_order": item.SortOrder,
})
}
if err = imworkbench.DefaultClient(ctx).Post(ctx, "/internal/admin/workbench/apps/reorder", g.Map{"items": items}, nil); err != nil {
if err = 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工作台模块排序失败,请稍后重试!")
}
+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
}
@@ -1,29 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// AdminOperationLog is the golang structure of table cv_admin_operation_log for DAO operations like Where/Data.
type AdminOperationLog struct {
g.Meta `orm:"table:cv_admin_operation_log, do:true"`
Id any //
TenantId any //
OperatorId any //
OperationType any //
TargetType any //
TargetId any //
RequestId any //
BeforeSnapshot *gjson.Json //
AfterSnapshot *gjson.Json //
Reason any //
IpAddress any //
UserAgent any //
CreatedAt *gtime.Time //
}
@@ -1,29 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ContentAuditLog is the golang structure of table cv_content_audit_log for DAO operations like Where/Data.
type ContentAuditLog struct {
g.Meta `orm:"table:cv_content_audit_log, do:true"`
Id any //
TenantId any //
ObjectType any //
ObjectId any //
AuditRequestId any //
AuditSource any //
StatusBefore any //
StatusAfter any //
ResultCode any //
RiskLabels *gjson.Json //
OperatorId any //
OccurredAt *gtime.Time //
CreatedAt *gtime.Time //
}
-29
View File
@@ -1,29 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedComment is the golang structure of table cv_feed_comment for DAO operations like Where/Data.
type FeedComment struct {
g.Meta `orm:"table:cv_feed_comment, do:true"`
Id any //
TenantId any //
PostId any //
AuthorId any //
ContentText any //
Status any //
AuditStatus any //
AuditRequestId any //
DeletedBy any //
DeleteReason any //
PublishedAt *gtime.Time //
DeletedAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
-29
View File
@@ -1,29 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedMedia is the golang structure of table cv_feed_media for DAO operations like Where/Data.
type FeedMedia struct {
g.Meta `orm:"table:cv_feed_media, do:true"`
Id any //
TenantId any //
PostId any //
UserId any //
MediaType any //
FileId any //
ThumbnailFileId any //
Width any //
Height any //
SortNo any //
AuditStatus any //
AuditRequestId any //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
-35
View File
@@ -1,35 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedPost is the golang structure of table cv_feed_post for DAO operations like Where/Data.
type FeedPost struct {
g.Meta `orm:"table:cv_feed_post, do:true"`
Id any //
TenantId any //
AuthorId any //
ContentText any //
FlowerCode any //
SourceType any //
SourceId any //
Visibility any //
Status any //
AuditStatus any //
AuditRequestId any //
RejectReasonCode any //
LikeCount any //
CommentCount any //
Version any //
PublishedAt *gtime.Time //
DeletedAt *gtime.Time //
TakenDownAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
-32
View File
@@ -1,32 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedReport is the golang structure of table cv_feed_report for DAO operations like Where/Data.
type FeedReport struct {
g.Meta `orm:"table:cv_feed_report, do:true"`
Id any //
TenantId any //
ReporterId any //
ReportedUserId any //
ObjectType any //
ObjectId any //
ReasonCode any //
Description any //
EvidenceFileIds *gjson.Json //
SourcePage any //
Status any //
HandlerId any //
HandleResult any //
HandledAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
@@ -1,32 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerCatalog is the golang structure of table cv_flower_catalog for DAO operations like Where/Data.
type FlowerCatalog struct {
g.Meta `orm:"table:cv_flower_catalog, do:true"`
Id any //
TenantId any //
FlowerCode any //
FlowerName any //
FlowerDesc any //
IconFileId any //
Status any //
ConfigVersion any //
DailyWaterLimit any //
DailyFertilizeLimit any //
ShareConfig *gjson.Json //
SortNo any //
OnlineAt *gtime.Time //
OfflineAt *gtime.Time //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
@@ -1,32 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerStageRule is the golang structure of table cv_flower_stage_rule for DAO operations like Where/Data.
type FlowerStageRule struct {
g.Meta `orm:"table:cv_flower_stage_rule, do:true"`
Id any //
TenantId any //
FlowerCatalogId any //
Stage any //
NextStage any //
RequiredPoints any //
WaterPoints any //
FertilizePoints any //
AllowedActions *gjson.Json //
ImageFileId any //
AnimationFileId any //
ProgressTextTemplate any //
CompletionText any //
SortNo any //
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
}
+11 -2
View File
@@ -16,8 +16,17 @@ type ImTenant struct {
Code any //
Name any //
TenantDesc any //
DbName any //
DbDsn any //
MaxUsers any // 0表示不限制
MaxGroups any // 0表示不限制
MaxGroupMembers any // 0表示不限制
RealnameReviewPolicy any // 实名审核策略:0自动通过,1转人工审核
AliyunModerationEnabled any // 是否启用阿里云内容审核
LocalModerationEnabled any // 是否启用本地内容审核
MessageEncryptionEnabled any // 是否启用消息加密
DefaultPolicyGroupId any // 默认用户策略组
SecurityPolicyVersion any // 已发布安全策略版本
GlobalFriendEnabled any // 全局加好友开关
MessageCurfewEnabled any // 消息宵禁开关
State any //
CreatedAt *gtime.Time //
CreatedBy any //
@@ -1,28 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantConfig is the golang structure of table im_tenant_config for DAO operations like Where/Data.
type ImTenantConfig struct {
g.Meta `orm:"table:im_tenant_config, do:true"`
Id any //
TenantId any //
MaxUsers any // 0 means unlimited
MaxGroups any // 0 means unlimited
MaxGroupMembers any // 0 means unlimited
DefaultPolicyGroupId any // Default user policy group
SecurityPolicyVersion any // Published security policy version
GlobalFriendEnabled any // Published global friend switch
MessageCurfewEnabled any // Published message curfew switch
CreatedAt *gtime.Time //
CreatedBy any //
UpdatedAt *gtime.Time //
UpdatedBy any //
}
@@ -1,27 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// AdminOperationLog is the golang structure for table admin_operation_log.
type AdminOperationLog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
OperatorId uint64 `json:"operatorId" orm:"operator_id" description:""`
OperationType string `json:"operationType" orm:"operation_type" description:""`
TargetType string `json:"targetType" orm:"target_type" description:""`
TargetId string `json:"targetId" orm:"target_id" description:""`
RequestId string `json:"requestId" orm:"request_id" description:""`
BeforeSnapshot *gjson.Json `json:"beforeSnapshot" orm:"before_snapshot" description:""`
AfterSnapshot *gjson.Json `json:"afterSnapshot" orm:"after_snapshot" description:""`
Reason string `json:"reason" orm:"reason" description:""`
IpAddress string `json:"ipAddress" orm:"ip_address" description:""`
UserAgent string `json:"userAgent" orm:"user_agent" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
}
@@ -1,27 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// ContentAuditLog is the golang structure for table content_audit_log.
type ContentAuditLog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
ObjectType string `json:"objectType" orm:"object_type" description:""`
ObjectId uint64 `json:"objectId" orm:"object_id" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
AuditSource string `json:"auditSource" orm:"audit_source" description:""`
StatusBefore string `json:"statusBefore" orm:"status_before" description:""`
StatusAfter string `json:"statusAfter" orm:"status_after" description:""`
ResultCode string `json:"resultCode" orm:"result_code" description:""`
RiskLabels *gjson.Json `json:"riskLabels" orm:"risk_labels" description:""`
OperatorId uint64 `json:"operatorId" orm:"operator_id" description:""`
OccurredAt *gtime.Time `json:"occurredAt" orm:"occurred_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
}
@@ -1,27 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedComment is the golang structure for table feed_comment.
type FeedComment struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
PostId uint64 `json:"postId" orm:"post_id" description:""`
AuthorId uint64 `json:"authorId" orm:"author_id" description:""`
ContentText string `json:"contentText" orm:"content_text" description:""`
Status string `json:"status" orm:"status" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:""`
DeleteReason string `json:"deleteReason" orm:"delete_reason" description:""`
PublishedAt *gtime.Time `json:"publishedAt" orm:"published_at" description:""`
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -1,27 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedMedia is the golang structure for table feed_media.
type FeedMedia struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
PostId uint64 `json:"postId" orm:"post_id" description:""`
UserId uint64 `json:"userId" orm:"user_id" description:""`
MediaType string `json:"mediaType" orm:"media_type" description:""`
FileId string `json:"fileId" orm:"file_id" description:""`
ThumbnailFileId string `json:"thumbnailFileId" orm:"thumbnail_file_id" description:""`
Width uint `json:"width" orm:"width" description:""`
Height uint `json:"height" orm:"height" description:""`
SortNo uint `json:"sortNo" orm:"sort_no" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
-33
View File
@@ -1,33 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// FeedPost is the golang structure for table feed_post.
type FeedPost struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
AuthorId uint64 `json:"authorId" orm:"author_id" description:""`
ContentText string `json:"contentText" orm:"content_text" description:""`
FlowerCode string `json:"flowerCode" orm:"flower_code" description:""`
SourceType string `json:"sourceType" orm:"source_type" description:""`
SourceId uint64 `json:"sourceId" orm:"source_id" description:""`
Visibility string `json:"visibility" orm:"visibility" description:""`
Status string `json:"status" orm:"status" description:""`
AuditStatus string `json:"auditStatus" orm:"audit_status" description:""`
AuditRequestId string `json:"auditRequestId" orm:"audit_request_id" description:""`
RejectReasonCode string `json:"rejectReasonCode" orm:"reject_reason_code" description:""`
LikeCount uint `json:"likeCount" orm:"like_count" description:""`
CommentCount uint `json:"commentCount" orm:"comment_count" description:""`
Version uint `json:"version" orm:"version" description:""`
PublishedAt *gtime.Time `json:"publishedAt" orm:"published_at" description:""`
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""`
TakenDownAt *gtime.Time `json:"takenDownAt" orm:"taken_down_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -1,30 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FeedReport is the golang structure for table feed_report.
type FeedReport struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
ReporterId uint64 `json:"reporterId" orm:"reporter_id" description:""`
ReportedUserId uint64 `json:"reportedUserId" orm:"reported_user_id" description:""`
ObjectType string `json:"objectType" orm:"object_type" description:""`
ObjectId string `json:"objectId" orm:"object_id" description:""`
ReasonCode string `json:"reasonCode" orm:"reason_code" description:""`
Description string `json:"description" orm:"description" description:""`
EvidenceFileIds *gjson.Json `json:"evidenceFileIds" orm:"evidence_file_ids" description:""`
SourcePage string `json:"sourcePage" orm:"source_page" description:""`
Status string `json:"status" orm:"status" description:""`
HandlerId uint64 `json:"handlerId" orm:"handler_id" description:""`
HandleResult string `json:"handleResult" orm:"handle_result" description:""`
HandledAt *gtime.Time `json:"handledAt" orm:"handled_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -1,30 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerCatalog is the golang structure for table flower_catalog.
type FlowerCatalog struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
FlowerCode string `json:"flowerCode" orm:"flower_code" description:""`
FlowerName string `json:"flowerName" orm:"flower_name" description:""`
FlowerDesc string `json:"flowerDesc" orm:"flower_desc" description:""`
IconFileId string `json:"iconFileId" orm:"icon_file_id" description:""`
Status string `json:"status" orm:"status" description:""`
ConfigVersion string `json:"configVersion" orm:"config_version" description:""`
DailyWaterLimit uint `json:"dailyWaterLimit" orm:"daily_water_limit" description:""`
DailyFertilizeLimit uint `json:"dailyFertilizeLimit" orm:"daily_fertilize_limit" description:""`
ShareConfig *gjson.Json `json:"shareConfig" orm:"share_config" description:""`
SortNo int `json:"sortNo" orm:"sort_no" description:""`
OnlineAt *gtime.Time `json:"onlineAt" orm:"online_at" description:""`
OfflineAt *gtime.Time `json:"offlineAt" orm:"offline_at" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
@@ -1,30 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
// FlowerStageRule is the golang structure for table flower_stage_rule.
type FlowerStageRule struct {
Id uint64 `json:"id" orm:"id" description:""`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:""`
FlowerCatalogId uint64 `json:"flowerCatalogId" orm:"flower_catalog_id" description:""`
Stage string `json:"stage" orm:"stage" description:""`
NextStage string `json:"nextStage" orm:"next_stage" description:""`
RequiredPoints uint `json:"requiredPoints" orm:"required_points" description:""`
WaterPoints uint `json:"waterPoints" orm:"water_points" description:""`
FertilizePoints uint `json:"fertilizePoints" orm:"fertilize_points" description:""`
AllowedActions *gjson.Json `json:"allowedActions" orm:"allowed_actions" description:""`
ImageFileId string `json:"imageFileId" orm:"image_file_id" description:""`
AnimationFileId string `json:"animationFileId" orm:"animation_file_id" description:""`
ProgressTextTemplate string `json:"progressTextTemplate" orm:"progress_text_template" description:""`
CompletionText string `json:"completionText" orm:"completion_text" description:""`
SortNo int `json:"sortNo" orm:"sort_no" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
}
+11 -2
View File
@@ -14,8 +14,17 @@ type ImTenant struct {
Code string `json:"code" orm:"code" description:""`
Name string `json:"name" orm:"name" description:""`
TenantDesc string `json:"tenantDesc" orm:"tenant_desc" description:""`
DbName string `json:"dbName" orm:"db_name" description:""`
DbDsn string `json:"dbDsn" orm:"db_dsn" description:""`
MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0表示不限制"`
MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0表示不限制"`
MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0表示不限制"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" orm:"realname_review_policy" description:"实名审核策略:0自动通过,1转人工审核"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" orm:"aliyun_moderation_enabled" description:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" orm:"local_moderation_enabled" description:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" orm:"message_encryption_enabled" description:"是否启用消息加密"`
DefaultPolicyGroupId int64 `json:"defaultPolicyGroupId" orm:"default_policy_group_id" description:"默认用户策略组"`
SecurityPolicyVersion uint `json:"securityPolicyVersion" orm:"security_policy_version" description:"已发布安全策略版本"`
GlobalFriendEnabled int `json:"globalFriendEnabled" orm:"global_friend_enabled" description:"全局加好友开关"`
MessageCurfewEnabled int `json:"messageCurfewEnabled" orm:"message_curfew_enabled" description:"消息宵禁开关"`
State int `json:"state" orm:"state" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
CreatedBy int64 `json:"createdBy" orm:"created_by" description:""`
@@ -1,26 +0,0 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantConfig is the golang structure for table im_tenant_config.
type ImTenantConfig struct {
Id int64 `json:"id" orm:"id" description:""`
TenantId int64 `json:"tenantId" orm:"tenant_id" description:""`
MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0 means unlimited"`
MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0 means unlimited"`
MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0 means unlimited"`
DefaultPolicyGroupId int64 `json:"defaultPolicyGroupId" orm:"default_policy_group_id" description:"Default user policy group"`
SecurityPolicyVersion uint `json:"securityPolicyVersion" orm:"security_policy_version" description:"Published security policy version"`
GlobalFriendEnabled int `json:"globalFriendEnabled" orm:"global_friend_enabled" description:"Published global friend switch"`
MessageCurfewEnabled int `json:"messageCurfewEnabled" orm:"message_curfew_enabled" description:"Published message curfew switch"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
CreatedBy int64 `json:"createdBy" orm:"created_by" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""`
}
@@ -18,6 +18,7 @@ type CultivateStageRuleEditInp struct {
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"`
@@ -64,8 +65,8 @@ func (in *CultivateFlowerEditInp) Filter(ctx context.Context) error {
return gerror.New("成长阶段不能重复")
}
seen[rule.Stage] = true
if rule.Stage != "bloom" && rule.RequiredPoints == 0 {
return gerror.New("非开花阶段升级阈值必须大于0")
if rule.Stage != "bloom" && rule.RequiredPoints == 0 && rule.WaitHours == 0 {
return gerror.New("非开花阶段必须配置升级阈值或等待时长")
}
if rule.NextStage != "" && !validStage[rule.NextStage] {
return gerror.New("下一阶段不正确")
@@ -16,13 +16,18 @@ import (
type ImAppReleaseListInp struct {
form.PageReq
AppCode string `json:"appCode" dc:"应用标识"`
Platform string `json:"platform" dc:"平台"`
Channel string `json:"channel" dc:"渠道"`
}
func (in *ImAppReleaseListInp) Filter(ctx context.Context) (err error) {
in.AppCode = strings.TrimSpace(in.AppCode)
in.Platform = strings.TrimSpace(in.Platform)
in.Channel = strings.TrimSpace(in.Channel)
if in.AppCode != "" && !dict.HasOptionKey(consts.ImAppCodeOptions, in.AppCode) {
return gerror.New("应用标识不合法")
}
if in.Platform != "" && !dict.HasOptionKey(consts.ImAppPlatformOptions, in.Platform) {
return gerror.New("客户端平台不合法")
}
@@ -31,6 +36,7 @@ func (in *ImAppReleaseListInp) Filter(ctx context.Context) (err error) {
type ImAppReleaseModel struct {
Id int64 `json:"id" dc:"ID"`
AppCode string `json:"appCode" dc:"应用标识"`
Platform string `json:"platform" dc:"平台"`
Channel string `json:"channel" dc:"渠道"`
LatestVersion string `json:"latestVersion" dc:"最新版本"`
@@ -48,6 +54,7 @@ type ImAppReleaseModel struct {
type ImAppReleaseEditInp struct {
Id int64 `json:"id" dc:"ID"`
AppCode string `json:"appCode" dc:"应用标识"`
Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"`
Channel string `json:"channel" v:"required#渠道不能为空" dc:"渠道"`
LatestVersion string `json:"latestVersion" v:"required#最新版本不能为空" dc:"最新版本"`
@@ -61,6 +68,7 @@ type ImAppReleaseEditInp struct {
}
func (in *ImAppReleaseEditInp) Filter(ctx context.Context) (err error) {
in.AppCode = strings.TrimSpace(in.AppCode)
in.Platform = strings.TrimSpace(in.Platform)
in.Channel = strings.TrimSpace(in.Channel)
in.LatestVersion = strings.TrimSpace(in.LatestVersion)
@@ -69,6 +77,12 @@ func (in *ImAppReleaseEditInp) Filter(ctx context.Context) (err error) {
in.StoreUrl = strings.TrimSpace(in.StoreUrl)
in.PackageUrl = strings.TrimSpace(in.PackageUrl)
in.Checksum = strings.TrimSpace(in.Checksum)
if in.AppCode == "" {
in.AppCode = consts.ImAppCodeIm
}
if !dict.HasOptionKey(consts.ImAppCodeOptions, in.AppCode) {
return gerror.New("应用标识不合法")
}
if !dict.HasOptionKey(consts.ImAppPlatformOptions, in.Platform) {
return gerror.New("客户端平台不合法")
}
@@ -100,6 +100,7 @@ type ImLoginFailureLogModel struct {
UserAgent string `json:"userAgent" dc:"客户端标识"`
RiskLevel string `json:"riskLevel" dc:"风险等级"`
RiskAction string `json:"riskAction" dc:"风控动作"`
LockedNow bool `json:"lockedNow" dc:"当前是否仍处于锁定中(实时状态,非历史快照)"`
RequestId string `json:"requestId" dc:"请求ID"`
AttemptAt *gtime.Time `json:"attemptAt" dc:"尝试时间"`
Metadata string `json:"metadata" dc:"元数据"`
@@ -78,16 +78,6 @@ func TestImSecurityPolicySaveFilterForcesEveryFriendEntryOff(t *testing.T) {
}
}
func TestImSecurityPolicyPublishFilter(t *testing.T) {
ctx := context.Background()
if err := (&ImSecurityPolicyPublishInp{Id: 1, Reason: " "}).Filter(ctx); err == nil {
t.Fatal("空发布原因应被拒绝")
}
if err := (&ImSecurityPolicyPublishInp{Id: 1, Reason: "管理员发布"}).Filter(ctx); err != nil {
t.Fatalf("合法发布参数不应报错: %v", err)
}
}
func TestImSecurityIPRuleEditFilter(t *testing.T) {
ctx := context.Background()
valid := &ImSecurityIPRuleEditInp{
@@ -0,0 +1,23 @@
// Package sysin
// @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 sysin
import "github.com/gogf/gf/v2/os/gtime"
// ImPublicUploadModel 企业注册入口公共上传文件
type ImPublicUploadModel struct {
Id int64 `json:"id" dc:"ID"`
FileId string `json:"fileId" dc:"文件ID"`
Purpose string `json:"purpose" dc:"用途"`
MimeType string `json:"mimeType" dc:"MIME类型"`
Size int64 `json:"size" dc:"文件大小"`
Etag string `json:"etag" dc:"ETag"`
Location string `json:"location" dc:"存储位置"`
Url string `json:"url" dc:"访问地址"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UploadedAt *gtime.Time `json:"uploadedAt" dc:"上传完成时间"`
}
@@ -255,25 +255,6 @@ func normalizeLoginRateLimit(value *gjson.Json) (*gjson.Json, error) {
return gjson.New(map[string]any{"enabled": true, "password_max_attempts": maxAttempts, "lock_seconds": lockSeconds, "window_seconds": windowSeconds}), nil
}
type ImSecurityPolicyPublishInp struct {
Id int64 `json:"id" v:"required|min:1#策略ID不能为空|策略ID不合法"`
Reason string `json:"reason" v:"required#发布原因不能为空"`
}
func (in *ImSecurityPolicyPublishInp) Filter(ctx context.Context) error {
in.Reason = strings.TrimSpace(in.Reason)
if in.Id <= 0 {
return gerror.New("策略ID不合法")
}
if in.Reason == "" {
return gerror.New("发布原因不能为空")
}
if len([]rune(in.Reason)) > 500 {
return gerror.New("发布原因不能超过500个字符")
}
return nil
}
type ImSecurityPolicyRestoreInp struct {
TenantId int64 `json:"tenantId"`
Reason string `json:"reason" v:"required#恢复原因不能为空"`
+12 -52
View File
@@ -20,51 +20,25 @@ import (
// ImTenantInsertFields 新增IM租户字段过滤
type ImTenantInsertFields struct {
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
TenantDesc string `json:"tenantDesc" dc:"租户描述"`
DbName string `json:"dbName" dc:"业务库名"`
DbDsn string `json:"dbDsn" dc:"独立连接串"`
Id int64 `json:"id" dc:"商户ID"`
Code string `json:"code" dc:"商户编码"`
Name string `json:"name" dc:"商户名称"`
TenantDesc string `json:"tenantDesc" dc:"商户描述"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
State int `json:"state" dc:"状态"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
}
// ImTenantConfigUpdateFields 修改IM租户配置字段过滤
type ImTenantConfigUpdateFields struct {
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
}
// ImTenantConfigInsertFields 新增IM租户配置字段过滤
type ImTenantConfigInsertFields struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
}
// ImTenantEditInp 修改/新增IM租户
type ImTenantEditInp struct {
entity.ImTenant
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
AdminUsername string `json:"adminUsername" dc:"新增商户时自动创建的后台管理员账号名"`
AdminPassword string `json:"adminPassword" dc:"新增商户时自动创建的后台管理员初始密码"`
}
@@ -119,27 +93,17 @@ func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) {
type ImTenantViewModel struct {
entity.ImTenant
AdminUsername string `json:"adminUsername" dc:"商户管理员账号"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"`
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"`
UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"`
DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"`
TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"`
TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"`
}
// ImTenantListInp 获取IM租户列表
type ImTenantListInp struct {
form.PageReq
form.Sorters
Id int64 `json:"id" dc:"ID"`
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
DbName string `json:"dbName" dc:"业务库名"`
State *int `json:"state" dc:"状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
@@ -153,7 +117,6 @@ type ImTenantListModel struct {
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
TenantDesc string `json:"tenantDesc" dc:"租户描述"`
DbName string `json:"dbName" dc:"业务库名"`
State int `json:"state" dc:"状态"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
@@ -162,7 +125,6 @@ type ImTenantListModel struct {
LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"`
MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"`
RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"`
DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
@@ -204,8 +166,6 @@ type ImTenantUsageModel struct {
Id int64 `json:"id" dc:"租户ID"`
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
DbName string `json:"dbName" dc:"业务库名"`
DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"`
DbReachable bool `json:"dbReachable" dc:"业务库是否可达"`
DbCheckError string `json:"dbCheckError" dc:"业务库检查错误"`
SchemaVersion string `json:"schemaVersion" dc:"Schema版本"`
@@ -0,0 +1,63 @@
// Package sysin
// @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 sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantApplicationListInp 获取企业注册申请列表
type ImTenantApplicationListInp struct {
form.PageReq
ApplicationId string `json:"applicationId" dc:"申请ID"`
CreditCode string `json:"creditCode" dc:"统一社会信用代码"`
EnterpriseEmail string `json:"enterpriseEmail" dc:"企业邮箱"`
TenantCodeHint string `json:"tenantCodeHint" dc:"商户编码线索"`
Status string `json:"status" dc:"状态"`
Keyword string `json:"keyword" dc:"关键词"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImTenantApplicationListInp) Filter(ctx context.Context) (err error) {
return
}
type ImTenantApplicationListModel struct {
Id int64 `json:"id" dc:"ID"`
ApplicationId string `json:"applicationId" dc:"申请ID"`
CreditCode string `json:"creditCode" dc:"统一社会信用代码"`
EnterpriseEmail string `json:"enterpriseEmail" dc:"企业邮箱"`
LicenseFileId string `json:"licenseFileId" dc:"营业执照文件ID"`
TenantCodeHint string `json:"tenantCodeHint" dc:"商户编码线索"`
Status string `json:"status" dc:"状态"`
EstimatedReviewHour int `json:"estimatedReviewHour" dc:"预计审核小时"`
SubmittedAt *gtime.Time `json:"submittedAt" dc:"提交时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImTenantApplicationViewInp 获取企业注册申请详情
type ImTenantApplicationViewInp struct {
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImTenantApplicationViewInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
return
}
type ImTenantApplicationViewModel struct {
ImTenantApplicationListModel
ClientContext string `json:"clientContext" dc:"客户端上下文"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
LicenseFile *ImPublicUploadModel `json:"licenseFile" dc:"营业执照文件"`
}
@@ -0,0 +1,61 @@
// Package sysin
// @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 sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantEntryFeedbackListInp 获取企业入口反馈列表
type ImTenantEntryFeedbackListInp struct {
form.PageReq
FeedbackId string `json:"feedbackId" dc:"反馈ID"`
Contact string `json:"contact" dc:"联系方式"`
TenantCodeHint string `json:"tenantCodeHint" dc:"商户编码线索"`
Status string `json:"status" dc:"状态"`
Keyword string `json:"keyword" dc:"关键词"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImTenantEntryFeedbackListInp) Filter(ctx context.Context) (err error) {
return
}
type ImTenantEntryFeedbackListModel struct {
Id int64 `json:"id" dc:"ID"`
FeedbackId string `json:"feedbackId" dc:"反馈ID"`
Description string `json:"description" dc:"问题描述"`
Contact string `json:"contact" dc:"联系方式"`
TenantCodeHint string `json:"tenantCodeHint" dc:"商户编码线索"`
Status string `json:"status" dc:"状态"`
SubmittedAt *gtime.Time `json:"submittedAt" dc:"提交时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImTenantEntryFeedbackViewInp 获取企业入口反馈详情
type ImTenantEntryFeedbackViewInp struct {
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImTenantEntryFeedbackViewInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
return
}
type ImTenantEntryFeedbackViewModel struct {
ImTenantEntryFeedbackListModel
ScreenshotFileIds string `json:"screenshotFileIds" dc:"截图文件ID JSON"`
ClientContext string `json:"clientContext" dc:"客户端上下文"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
ScreenshotFiles []*ImPublicUploadModel `json:"screenshotFiles" dc:"截图文件"`
}

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