Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85393b2b8 | ||
|
|
75f811bb9f | ||
|
|
8773991015 | ||
|
|
5bf68727fa | ||
|
|
4f69dc302b | ||
|
|
bc3537bd9e | ||
|
|
86c5486cc4 | ||
|
|
c8413f82d9 | ||
|
|
0a3a406089 | ||
|
|
a39002b49d | ||
|
|
53accc3e3f | ||
|
|
06e3f0b489 | ||
|
|
ef2967b056 | ||
|
|
dacac13f88 | ||
|
|
5c805c7b09 | ||
|
|
7099a33cde | ||
|
|
fe7d4ccb3e | ||
|
|
9cfeb5f2c7 | ||
|
|
dd5986d57a | ||
|
|
ba0101297d | ||
|
|
f027764c47 | ||
|
|
ba6fd7f1b6 | ||
|
|
228e3a54fd | ||
|
|
fc246c3086 | ||
|
|
903a77f2c5 | ||
|
|
5789b85663 | ||
|
|
b83390bccd | ||
|
|
250a305fbf | ||
|
|
1bedf7e11c | ||
|
|
21c36abf58 | ||
|
|
cf08f7ceeb | ||
|
|
1d994c9302 | ||
|
|
bdb704832f | ||
|
|
edb9a6b2bb | ||
|
|
989e365492 | ||
|
|
c1c11560de | ||
|
|
8b5c5e3df5 | ||
|
|
4c964ee3ba | ||
|
|
982e71f093 | ||
|
|
482f9b80d9 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Deploy im-workbench (k3s test cluster)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
||||||
|
required: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: deploy-host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 确定镜像tag
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.inputs.image_tag }}"
|
||||||
|
[ -z "$TAG" ] && TAG=test
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 登录镜像仓库
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
||||||
|
|
||||||
|
- name: 构建并推送镜像
|
||||||
|
run: |
|
||||||
|
docker build -t git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }} .
|
||||||
|
docker push git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }}
|
||||||
|
|
||||||
|
- name: 部署到k3s测试集群
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||||
|
chmod 600 ~/.kube/config
|
||||||
|
kubectl rollout restart deployment/im-workbench -n workbench-system
|
||||||
|
kubectl rollout status deployment/im-workbench -n workbench-system --timeout=120s
|
||||||
+2
-1
@@ -3,6 +3,7 @@
|
|||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
#
|
#
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
@@ -24,6 +25,7 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
.idea/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
@@ -162,4 +164,3 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM golang:1.26 AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
|
||||||
|
|
||||||
|
FROM alpine:3.22
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV IM_WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
||||||
|
ENV IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin
|
||||||
|
ENV IM_WORKBENCH_INTERNAL_SECRET=hotgo-workbench-internal
|
||||||
|
ENV IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com
|
||||||
|
ENV IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench
|
||||||
|
ENV IM_CONFIG_API_SECRET=
|
||||||
|
ENV IM_INTERNAL_API_SECRET=
|
||||||
|
COPY --from=builder /out/im_workbench /app/im_workbench
|
||||||
|
COPY configs /app/configs
|
||||||
|
EXPOSE 6061
|
||||||
|
ENTRYPOINT ["/app/im_workbench"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: build test run
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/im_workbench ./cmd/workbench
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
||||||
@@ -1,2 +1,146 @@
|
|||||||
# olons_server
|
# im_workbench
|
||||||
|
|
||||||
|
工作台服务从 `im_server` 拆分后的独立 Go 工程。当前阶段只拆服务和接口边界,数据库暂不迁移,继续访问现有租户库中的工作台表。
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /metrics`
|
||||||
|
- `GET /v1/workbench`
|
||||||
|
- `/v1/workbench/organization/*`
|
||||||
|
- `/v1/workbench/attendance/*`
|
||||||
|
- `/v1/workbench/meetings*`
|
||||||
|
- `/v1/workbench/meeting-notes`
|
||||||
|
- `/v1/workbench/reports/daily*`
|
||||||
|
- `/v1/workbench/reports/monthly*`
|
||||||
|
- `/v1/workbench/executive-mailbox/*`
|
||||||
|
- `/internal/admin/workbench/*`,HotGo 后台内部管理接口,使用服务间 HMAC 鉴权
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json \
|
||||||
|
IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \
|
||||||
|
go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `:6061`,默认连接本地 `workbench` 库和本地 Redis。
|
||||||
|
|
||||||
|
开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-User-ID: 1
|
||||||
|
X-Tenant-ID: 1
|
||||||
|
X-Tenant-Code: default
|
||||||
|
```
|
||||||
|
|
||||||
|
正式链路使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
X-MeetingIM-APIKey: {apikey}
|
||||||
|
X-Tenant-Code: default
|
||||||
|
X-IM-Schema: 1
|
||||||
|
X-Request-ID: {uuid}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。
|
||||||
|
|
||||||
|
`usr...` 用户 ID 编解码优先从 IM Manager 内部配置接口读取 UID 加密 key,读不到时回退到
|
||||||
|
`auth.uid_key` 或环境变量 `IM_WORKBENCH_UID_KEY`。测试环境需要配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com
|
||||||
|
IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench
|
||||||
|
IM_WORKBENCH_MANAGER_SECRET={IM_CONFIG_API_SECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认读取 `GET /internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key`。
|
||||||
|
如果后台配置使用了其他 group/key,可通过 `IM_WORKBENCH_MANAGER_UID_KEY_GROUP` 和
|
||||||
|
`IM_WORKBENCH_MANAGER_UID_KEY_KEYS` 调整。
|
||||||
|
|
||||||
|
HotGo 后台访问 `/internal/admin/workbench/*` 时需要配置同一组内部鉴权参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin
|
||||||
|
IM_WORKBENCH_INTERNAL_SECRET={shared-secret}
|
||||||
|
```
|
||||||
|
|
||||||
|
测试环境也可以复用已有内部服务密钥:HotGo 的 `IM_WORKBENCH_API_SECRET` 为空时会使用
|
||||||
|
`IM_INTERNAL_API_SECRET`,`im_workbench` 的 `IM_WORKBENCH_INTERNAL_SECRET` 为空时也会使用
|
||||||
|
`IM_INTERNAL_API_SECRET`。
|
||||||
|
|
||||||
|
HotGo 后台在测试集群中建议直连工作台服务内网地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_API_BASE_URL=http://im-workbench.workbench-system.svc.cluster.local:6061
|
||||||
|
```
|
||||||
|
|
||||||
|
测试镜像默认使用 `hotgo-workbench-internal` 作为工作台内部签名密钥;正式环境建议通过
|
||||||
|
`IM_WORKBENCH_API_SECRET` 和 `IM_WORKBENCH_INTERNAL_SECRET` 覆盖为真实密钥。
|
||||||
|
|
||||||
|
## Kafka 事件
|
||||||
|
|
||||||
|
工作台写操作统一投递到 `im.workbench.event`。本地配置默认关闭 Kafka:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_KAFKA_ENABLED=true \
|
||||||
|
IM_WORKBENCH_KAFKA_BROKERS=127.0.0.1:9092 \
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json \
|
||||||
|
go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
测试环境使用 `configs/workbench.test.json`,Kafka 为明文 PLAINTEXT 直连,不配置 SASL/TLS。
|
||||||
|
|
||||||
|
## AI / DeepSeek 本地配置
|
||||||
|
|
||||||
|
工作台 AI 首版接入 DeepSeek。默认配置已在 `configs/workbench.local.json` 中给出,实际调用前需要配置 API Key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEEPSEEK_API_KEY=sk-xxx \
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json \
|
||||||
|
go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_DEFAULT_MODEL=deepseek-flash
|
||||||
|
DEEPSEEK_COMPLEX_MODEL=deepseek-v4-pro
|
||||||
|
DEEPSEEK_TIMEOUT_MS=60000
|
||||||
|
DEEPSEEK_MAX_RETRIES=2
|
||||||
|
DEEPSEEK_TEMPERATURE=0.2
|
||||||
|
DEEPSEEK_MAX_OUTPUT_TOKENS=4096
|
||||||
|
```
|
||||||
|
|
||||||
|
事件体字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "wbe_xxx",
|
||||||
|
"trace_id": "request-id",
|
||||||
|
"tenant_id": 1,
|
||||||
|
"tenant_code": "default",
|
||||||
|
"actor_user_id": 42,
|
||||||
|
"module": "meeting",
|
||||||
|
"action": "meeting.created",
|
||||||
|
"resource_id": "mtg_xxx",
|
||||||
|
"target_user_ids": [42, 43],
|
||||||
|
"title": "会议通知",
|
||||||
|
"summary": "项目周会",
|
||||||
|
"occurred_at": "2026-09-13T14:00:00Z",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库原则
|
||||||
|
|
||||||
|
服务启动不自动建库建表,不执行 `CREATE TABLE` / `ALTER TABLE`。所有表结构和种子数据继续通过 SQL 审核后人工执行。
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/app"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := app.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init app: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: server.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Printf("im_workbench listening on %s", cfg.Listen)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stopCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
log.Fatalf("serve: %v", err)
|
||||||
|
case <-stopCh:
|
||||||
|
log.Printf("shutdown requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"listen": ":6061",
|
||||||
|
"mysql": {
|
||||||
|
"dsn": "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local",
|
||||||
|
"max_open_conns": 15,
|
||||||
|
"max_idle_conns": 5
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"addr": "127.0.0.1:6379",
|
||||||
|
"db": 0,
|
||||||
|
"key_prefix": "im:"
|
||||||
|
},
|
||||||
|
"kafka": {
|
||||||
|
"enabled": true,
|
||||||
|
"brokers": [
|
||||||
|
"127.0.0.1:9092"
|
||||||
|
],
|
||||||
|
"topic": "im.workbench.event",
|
||||||
|
"client_id": "im_workbench_local"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"api_keys": [],
|
||||||
|
"session_key_pattern": "im:session:token:%s",
|
||||||
|
"uid_key": "dkXxWLwqUNRfYmdXRzOkZw==",
|
||||||
|
"allow_dev_header_auth": true,
|
||||||
|
"required_tenant_code": true,
|
||||||
|
"required_api_key": false,
|
||||||
|
"skip_session_in_dev": false,
|
||||||
|
"internal_service_name": "hotgo-admin",
|
||||||
|
"internal_secret": "local-workbench-secret"
|
||||||
|
},
|
||||||
|
"manager": {
|
||||||
|
"base_url": "",
|
||||||
|
"service_name": "im-workbench",
|
||||||
|
"secret": "",
|
||||||
|
"uid_key_group": "im",
|
||||||
|
"uid_key_keys": [
|
||||||
|
"uid_encryption_key",
|
||||||
|
"UID_ENCRYPTION_KEY",
|
||||||
|
"uid_key"
|
||||||
|
],
|
||||||
|
"timeout_ms": 3000
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"provider": "deepseek",
|
||||||
|
"default_model": "deepseek-flash",
|
||||||
|
"complex_model": "deepseek-v4-pro",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_output_tokens": 4096,
|
||||||
|
"deepseek": {
|
||||||
|
"base_url": "https://api.deepseek.com",
|
||||||
|
"api_key": "",
|
||||||
|
"timeout_ms": 60000,
|
||||||
|
"max_retries": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"listen": ":6061",
|
||||||
|
"mysql": {
|
||||||
|
"dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"addr": "redis.118im.com:30637",
|
||||||
|
"password": "HMTJzkm.h>Y+E9ep",
|
||||||
|
"db": 0,
|
||||||
|
"key_prefix": "im:"
|
||||||
|
},
|
||||||
|
"kafka": {
|
||||||
|
"enabled": true,
|
||||||
|
"brokers": [
|
||||||
|
"kafka.118im.com:30835"
|
||||||
|
],
|
||||||
|
"topic": "im.workbench.event",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"sasl": "",
|
||||||
|
"client_id": "im_workbench_test"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"api_keys": [],
|
||||||
|
"session_key_pattern": "im:session:token:%s",
|
||||||
|
"uid_key": "dkXxWLwqUNRfYmdXRzOkZw==",
|
||||||
|
"allow_dev_header_auth": false,
|
||||||
|
"required_tenant_code": true,
|
||||||
|
"required_api_key": true,
|
||||||
|
"skip_session_in_dev": false,
|
||||||
|
"internal_service_name": "hotgo-admin",
|
||||||
|
"internal_secret": ""
|
||||||
|
},
|
||||||
|
"manager": {
|
||||||
|
"base_url": "https://admin.118im.com",
|
||||||
|
"service_name": "im-workbench",
|
||||||
|
"secret": "",
|
||||||
|
"uid_key_group": "im",
|
||||||
|
"uid_key_keys": [
|
||||||
|
"uid_encryption_key",
|
||||||
|
"UID_ENCRYPTION_KEY",
|
||||||
|
"uid_key"
|
||||||
|
],
|
||||||
|
"timeout_ms": 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
|||||||
|
-- IM 工作台 AI 建表 SQL
|
||||||
|
-- Run on the shared meeting_im database.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_capability_config (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
tenant_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
feature VARCHAR(64) NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash',
|
||||||
|
daily_limit INT NOT NULL DEFAULT 0,
|
||||||
|
concurrency_limit INT NOT NULL DEFAULT 1,
|
||||||
|
max_input_chars INT NOT NULL DEFAULT 0,
|
||||||
|
max_file_size_mb INT NOT NULL DEFAULT 0,
|
||||||
|
config_json JSON NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_tenant_feature (tenant_id, feature)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_task (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
task_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_code VARCHAR(64) NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
feature VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
progress INT NOT NULL DEFAULT 0,
|
||||||
|
source_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
source_ref_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
input_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
input_snapshot JSON NULL,
|
||||||
|
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
result_ref_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
result_json JSON NULL,
|
||||||
|
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
retry_count INT NOT NULL DEFAULT 0,
|
||||||
|
started_at DATETIME(3) NULL,
|
||||||
|
finished_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_task_id (task_id),
|
||||||
|
KEY idx_user_feature_time (tenant_id, user_id, feature, created_at),
|
||||||
|
KEY idx_status_time (status, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_task_step (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
task_id VARCHAR(64) NOT NULL,
|
||||||
|
step VARCHAR(64) NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
progress INT NOT NULL DEFAULT 0,
|
||||||
|
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
started_at DATETIME(3) NULL,
|
||||||
|
finished_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_task_step (task_id, step)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_record (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
record_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_code VARCHAR(64) NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
record_type VARCHAR(64) NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
summary VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
detail_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
deleted_at DATETIME(3) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_record_id (record_id),
|
||||||
|
KEY idx_user_type_time (tenant_id, user_id, record_type, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_document (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
record_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
document_type VARCHAR(64) NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
content_markdown MEDIUMTEXT NOT NULL,
|
||||||
|
content_plain MEDIUMTEXT NULL,
|
||||||
|
sections_json JSON NULL,
|
||||||
|
source_excerpt VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_record_id (record_id),
|
||||||
|
KEY idx_tenant_user_time (tenant_id, user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_table_analysis (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
record_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
file_id VARCHAR(128) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
file_type VARCHAR(16) NOT NULL,
|
||||||
|
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
topic VARCHAR(128) NOT NULL,
|
||||||
|
metrics_json JSON NULL,
|
||||||
|
preview_json JSON NULL,
|
||||||
|
alerts_json JSON NULL,
|
||||||
|
interpretation MEDIUMTEXT NULL,
|
||||||
|
export_file_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_record_id (record_id),
|
||||||
|
KEY idx_file_id (tenant_id, file_id),
|
||||||
|
KEY idx_tenant_user_time (tenant_id, user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_meeting_summary (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
summary_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_code VARCHAR(64) NOT NULL,
|
||||||
|
meeting_id VARCHAR(128) NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
conclusions_json JSON NULL,
|
||||||
|
decisions_json JSON NULL,
|
||||||
|
action_items_json JSON NULL,
|
||||||
|
source_file_ids_json JSON NULL,
|
||||||
|
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
edited_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
edited_at DATETIME(3) NULL,
|
||||||
|
version INT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_summary_id (summary_id),
|
||||||
|
UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id),
|
||||||
|
KEY idx_user_time (tenant_id, user_id, updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_usage_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_code VARCHAR(64) NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
feature VARCHAR(64) NOT NULL,
|
||||||
|
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
provider VARCHAR(32) NOT NULL,
|
||||||
|
model VARCHAR(64) NOT NULL,
|
||||||
|
provider_request_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
completion_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
latency_ms INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
input_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
output_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_user_feature_day (tenant_id, user_id, feature, created_at),
|
||||||
|
KEY idx_provider_time (provider, model, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_idempotency_record (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
idempotency_key VARCHAR(128) NOT NULL,
|
||||||
|
request_hash VARCHAR(128) NOT NULL,
|
||||||
|
response_code INT NOT NULL DEFAULT 0,
|
||||||
|
response_body JSON NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wb_ai_prompt_template (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
template_key VARCHAR(64) NOT NULL,
|
||||||
|
version VARCHAR(64) NOT NULL,
|
||||||
|
provider VARCHAR(32) NOT NULL DEFAULT 'deepseek',
|
||||||
|
model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash',
|
||||||
|
system_prompt MEDIUMTEXT NOT NULL,
|
||||||
|
user_prompt_template MEDIUMTEXT NOT NULL,
|
||||||
|
response_schema JSON NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_template_version (template_key, version),
|
||||||
|
KEY idx_template_enabled (template_key, enabled)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
INSERT INTO wb_ai_capability_config
|
||||||
|
(tenant_id, tenant_code, feature, enabled, model, daily_limit, concurrency_limit, max_input_chars, max_file_size_mb, config_json, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(0, '', 'smart_document', 0, 'deepseek-flash', 20, 1, 1000, 0, JSON_OBJECT('entry_title', '智能文档'), NOW(3), NOW(3)),
|
||||||
|
(0, '', 'smart_table', 0, 'deepseek-v4-pro', 10, 1, 100, 20, JSON_OBJECT('entry_title', '智能表格', 'allowed_file_types', JSON_ARRAY('xlsx', 'xls', 'csv')), NOW(3), NOW(3)),
|
||||||
|
(0, '', 'text_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||||
|
(0, '', 'daily_report_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||||
|
(0, '', 'monthly_report_polish', 0, 'deepseek-flash', 30, 3, 1000, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||||
|
(0, '', 'meeting_summary', 0, 'deepseek-v4-pro', 10, 1, 0, 0, JSON_OBJECT('entry_title', 'AI总结'), NOW(3), NOW(3))
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
model = VALUES(model),
|
||||||
|
daily_limit = VALUES(daily_limit),
|
||||||
|
concurrency_limit = VALUES(concurrency_limit),
|
||||||
|
max_input_chars = VALUES(max_input_chars),
|
||||||
|
max_file_size_mb = VALUES(max_file_size_mb),
|
||||||
|
config_json = VALUES(config_json),
|
||||||
|
updated_at = NOW(3);
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# 工作台与 Manager 内部配置运维说明
|
||||||
|
|
||||||
|
更新时间:2026-09-17
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
`im_workbench` 需要从 `im_manager` 读取系统配置,例如:
|
||||||
|
|
||||||
|
- `im.uid_encryption_key`
|
||||||
|
|
||||||
|
读取链路如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im_workbench
|
||||||
|
-> GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key
|
||||||
|
-> im_manager 校验 HMAC 签名
|
||||||
|
-> 返回系统配置
|
||||||
|
```
|
||||||
|
|
||||||
|
这个接口是服务间内部接口,不是普通前端接口。调用方必须带 HMAC 请求头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Service-Name: im-workbench
|
||||||
|
X-Timestamp: <unix seconds>
|
||||||
|
X-Nonce: <random nonce>
|
||||||
|
X-Signature: <hmac-sha256 hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
签名使用的密钥来自 `im_manager` 测试环境 Secret 中的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager-system / im-manager-secrets / IM_CONFIG_API_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:不要把真实密钥写进 Git 文档或代码。
|
||||||
|
|
||||||
|
## 2. 为什么 .env 不生效
|
||||||
|
|
||||||
|
测试环境运行在 Kubernetes Pod 中,服务启动时不会自动读取仓库里的 `.env` 文件。
|
||||||
|
|
||||||
|
当前代码和启动方式是:
|
||||||
|
|
||||||
|
- `im_workbench` 只读取进程环境变量和 `IM_WORKBENCH_CONFIG` 指向的 JSON 配置文件。
|
||||||
|
- `im_manager` 的 entrypoint 使用 Pod 环境变量做 `envsubst` 生成 `config.yaml`。
|
||||||
|
- 两个服务都没有 `godotenv.Load()`、`source .env` 或 docker compose `env_file` 逻辑。
|
||||||
|
|
||||||
|
所以测试环境真正生效的是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kubernetes Secret / Deployment 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
不是仓库中的 `.env`。
|
||||||
|
|
||||||
|
## 3. 当前 Kubernetes 配置关系
|
||||||
|
|
||||||
|
### im_workbench
|
||||||
|
|
||||||
|
命名空间:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workbench-system
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 Pod 里只显式注入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_CONFIG=/etc/im-workbench/workbench.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/im-workbench/workbench.json` 来自:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workbench-system / im-workbench-secrets / workbench.json
|
||||||
|
```
|
||||||
|
|
||||||
|
因此 workbench 的运行时配置要改这个 Secret 里的 `workbench.json`,或在 Deployment 里显式加环境变量。
|
||||||
|
|
||||||
|
### im_manager
|
||||||
|
|
||||||
|
命名空间:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager-system
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Secret:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager-system / im-manager-secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
其中和内部配置接口相关的 key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_CONFIG_API_SERVICE_NAME
|
||||||
|
IM_CONFIG_API_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 必须配置什么
|
||||||
|
|
||||||
|
### 4.1 im_manager 需要允许 im_workbench 调配置接口
|
||||||
|
|
||||||
|
`im_manager` 的 `IM_CONFIG_API_SERVICE_NAME` 需要支持两个调用方:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-server,im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- `im_server` 也需要读取 manager 配置。
|
||||||
|
- `im_workbench` 也需要读取 manager 配置。
|
||||||
|
- 如果只配置 `im-workbench`,会影响 `im_server`。
|
||||||
|
- 如果只配置 `im-server`,`im_workbench` 用正确 service name 调用会 401。
|
||||||
|
|
||||||
|
目标配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
`IM_CONFIG_API_SECRET` 保持现有值,不要改成空,也不要泄露到日志或文档。
|
||||||
|
|
||||||
|
### 4.2 im_workbench 需要配置 manager 连接信息
|
||||||
|
|
||||||
|
`workbench.json` 中需要有:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manager": {
|
||||||
|
"base_url": "https://admin.118im.com",
|
||||||
|
"service_name": "im-workbench",
|
||||||
|
"secret": "<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET>",
|
||||||
|
"uid_key_group": "im",
|
||||||
|
"uid_key_keys": [
|
||||||
|
"uid_encryption_key",
|
||||||
|
"UID_ENCRYPTION_KEY",
|
||||||
|
"uid_key"
|
||||||
|
],
|
||||||
|
"timeout_ms": 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以不用改 JSON,改成 Deployment 环境变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com
|
||||||
|
IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench
|
||||||
|
IM_WORKBENCH_MANAGER_SECRET=<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
建议优先统一放到 `workbench.json`,因为当前 workbench 已经通过 Secret 挂载 JSON 配置。
|
||||||
|
|
||||||
|
## 5. 推荐操作流程
|
||||||
|
|
||||||
|
### 步骤 1:更新 manager Secret
|
||||||
|
|
||||||
|
将:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager-system / im-manager-secrets / IM_CONFIG_API_SERVICE_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-server,im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
`IM_CONFIG_API_SECRET` 保持现有值。
|
||||||
|
|
||||||
|
### 步骤 2:更新 workbench Secret
|
||||||
|
|
||||||
|
从:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager-system / im-manager-secrets / IM_CONFIG_API_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
取值,写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workbench-system / im-workbench-secrets / workbench.json
|
||||||
|
```
|
||||||
|
|
||||||
|
目标字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manager": {
|
||||||
|
"base_url": "https://admin.118im.com",
|
||||||
|
"service_name": "im-workbench",
|
||||||
|
"secret": "<IM_CONFIG_API_SECRET>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:重启服务
|
||||||
|
|
||||||
|
更新 Secret 后需要重启 Pod,确保进程重新读取配置。
|
||||||
|
|
||||||
|
建议顺序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 重启 im-manager
|
||||||
|
2. 重启 im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 先让 manager 支持 `im-workbench` service name。
|
||||||
|
- 再让 workbench 使用 `im-workbench` service name 去调用 manager。
|
||||||
|
|
||||||
|
## 6. 验证方法
|
||||||
|
|
||||||
|
### 6.1 验证 manager 是否接受 im-workbench
|
||||||
|
|
||||||
|
用 `IM_CONFIG_API_SECRET` 对请求签名,请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key
|
||||||
|
X-Service-Name: im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
期望结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"values": {
|
||||||
|
"uid_encryption_key": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果返回 401,重点检查:
|
||||||
|
|
||||||
|
- `IM_CONFIG_API_SERVICE_NAME` 是否包含 `im-workbench`
|
||||||
|
- 请求里的 `X-Service-Name` 是否是 `im-workbench`
|
||||||
|
- `IM_CONFIG_API_SECRET` 是否和 manager 当前 Secret 一致
|
||||||
|
- 签名文本是否为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
METHOD
|
||||||
|
RequestURI
|
||||||
|
BodySHA256
|
||||||
|
Timestamp
|
||||||
|
Nonce
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 验证 workbench 是否加载到 manager 配置
|
||||||
|
|
||||||
|
进入 workbench Pod 看环境和配置摘要,注意不要打印真实 secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n workbench-system exec <im-workbench-pod> -- sh -c 'env | grep IM_WORKBENCH_MANAGER'
|
||||||
|
```
|
||||||
|
|
||||||
|
或查看挂载配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n workbench-system exec <im-workbench-pod> -- sh -c 'cat /etc/im-workbench/workbench.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manager.base_url = https://admin.118im.com
|
||||||
|
manager.service_name = im-workbench
|
||||||
|
manager.secret 非空
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 验证业务接口
|
||||||
|
|
||||||
|
从 IM 登录获取 token 后,请求 workbench 接口:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET https://api.118im.com/v1/workbench
|
||||||
|
Authorization: Bearer <IM token>
|
||||||
|
X-MeetingIM-APIKey: <api key>
|
||||||
|
X-IM-Schema: 1
|
||||||
|
X-Tenant-Code: <tenant code>
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
再创建会议,确认 user id 编解码和展示名正常:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api.118im.com/v1/workbench/meetings
|
||||||
|
Idempotency-Key: <unique id>
|
||||||
|
Authorization: Bearer <IM token>
|
||||||
|
X-MeetingIM-APIKey: <api key>
|
||||||
|
X-IM-Schema: 1
|
||||||
|
X-Tenant-Code: <tenant code>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "运维验证会议",
|
||||||
|
"start_at": "2026-09-18T08:00:00Z",
|
||||||
|
"end_at": "2026-09-18T09:00:00Z",
|
||||||
|
"location": "",
|
||||||
|
"recorder_user_id": "usr...",
|
||||||
|
"attendee_user_ids": ["usr..."],
|
||||||
|
"reminder_minutes": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"state": "scheduled",
|
||||||
|
"organizer": {
|
||||||
|
"user_id": "usr...",
|
||||||
|
"display_name": "..."
|
||||||
|
},
|
||||||
|
"participant_names": ["..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 常见问题
|
||||||
|
|
||||||
|
### 7.1 为什么不能从 manager 读取 manager secret
|
||||||
|
|
||||||
|
因为 `IM_CONFIG_API_SECRET` 是调用 manager 配置接口的 HMAC 引导凭证。
|
||||||
|
|
||||||
|
如果要读取 manager 配置接口,必须先有这个 secret 才能签名。
|
||||||
|
所以它不能从同一个 manager 配置接口中获取,只能来自部署环境,例如 Kubernetes Secret。
|
||||||
|
|
||||||
|
### 7.2 为什么 service name 不是 im-server
|
||||||
|
|
||||||
|
`X-Service-Name` 表示调用方服务名。
|
||||||
|
|
||||||
|
- `im_server` 调 manager 时,应使用 `im-server`
|
||||||
|
- `im_workbench` 调 manager 时,应使用 `im-workbench`
|
||||||
|
|
||||||
|
manager 侧应该允许多个合法调用方:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im-server,im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 为什么配置了 .env 还是没用
|
||||||
|
|
||||||
|
因为 Kubernetes 不会自动读取仓库 `.env`。
|
||||||
|
|
||||||
|
`.env` 只有在以下情况才会生效:
|
||||||
|
|
||||||
|
- 程序代码主动 `godotenv.Load()`
|
||||||
|
- 启动脚本 `source .env`
|
||||||
|
- docker compose 配了 `env_file`
|
||||||
|
- CI/CD 将 `.env` 内容写进 Deployment 或 Secret
|
||||||
|
|
||||||
|
当前测试环境都不是这些方式。
|
||||||
|
|
||||||
|
### 7.4 只改 workbench 会怎样
|
||||||
|
|
||||||
|
如果只把 workbench 改成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Service-Name: im-workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
但 manager 仍然只允许:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_CONFIG_API_SERVICE_NAME=im-server
|
||||||
|
```
|
||||||
|
|
||||||
|
那么 workbench 调 manager 会返回 401。
|
||||||
|
|
||||||
|
所以需要 manager 和 workbench 一起配置。
|
||||||
|
|
||||||
|
## 8. 最终配置清单
|
||||||
|
|
||||||
|
manager:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Namespace: manager-system
|
||||||
|
Secret: im-manager-secrets
|
||||||
|
|
||||||
|
IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench
|
||||||
|
IM_CONFIG_API_SECRET=<保持现有值>
|
||||||
|
```
|
||||||
|
|
||||||
|
workbench:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Namespace: workbench-system
|
||||||
|
Secret: im-workbench-secrets
|
||||||
|
Key: workbench.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`workbench.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manager": {
|
||||||
|
"base_url": "https://admin.118im.com",
|
||||||
|
"service_name": "im-workbench",
|
||||||
|
"secret": "<manager 的 IM_CONFIG_API_SECRET>",
|
||||||
|
"uid_key_group": "im",
|
||||||
|
"uid_key_keys": [
|
||||||
|
"uid_encryption_key",
|
||||||
|
"UID_ENCRYPTION_KEY",
|
||||||
|
"uid_key"
|
||||||
|
],
|
||||||
|
"timeout_ms": 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# 工作台迁移对接总结文档
|
||||||
|
|
||||||
|
> 更新时间:2026-09-14。本文用于后续开启新的 `im_workbench` 工作树时快速接手,覆盖当前迁移边界、接口、鉴权、中间件配置、Kafka 事件、数据库现状和验收步骤。
|
||||||
|
|
||||||
|
## 1. 当前结论
|
||||||
|
|
||||||
|
工作台已从 `im_server` 拆到独立仓库 `im_workbench`,当前阶段只拆服务和接口边界,数据库暂不迁移。工作台接口统一走 `/v1/workbench/*`,由网关分流到 `im_workbench`;登录仍由 `im_server` 负责,登录成功后把 Token 会话写入 Redis,`im_workbench` 从 Redis 校验登录态。
|
||||||
|
|
||||||
|
当前已完成:
|
||||||
|
|
||||||
|
| 项目 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 独立 Go 工程 | 已完成,仓库:`/Users/youshunlai/go_workspace/im_workbench` |
|
||||||
|
| 独立启动 | 已完成,默认监听 `:6061` |
|
||||||
|
| 新路由 `/v1/workbench/*` | 已完成 |
|
||||||
|
| Redis Token 鉴权 | 已完成 |
|
||||||
|
| 工作台 Kafka 事件生产 | 已完成 |
|
||||||
|
| 测试环境中间件配置 | 已更新 |
|
||||||
|
| 数据库迁移 | 暂不迁移,仍使用租户库 `tinode` |
|
||||||
|
| `im_server` 旧工作台代码 | 暂保留,等新服务稳定后再删 |
|
||||||
|
|
||||||
|
最近关键提交:
|
||||||
|
|
||||||
|
| 仓库 | Commit | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `im_workbench` | `8b5c5e3` | 兼容当前工作台表结构 |
|
||||||
|
| `im_workbench` | `c1c1156` | 更新新测试环境中间件配置,修复 Kafka 发布 |
|
||||||
|
|
||||||
|
## 2. 服务边界
|
||||||
|
|
||||||
|
### 2.1 im_workbench 负责
|
||||||
|
|
||||||
|
| 模块 | 接口范围 |
|
||||||
|
|---|---|
|
||||||
|
| 工作台首页 | `GET /v1/workbench` |
|
||||||
|
| 组织架构 | `/v1/workbench/organization/*` |
|
||||||
|
| 考勤 | `/v1/workbench/attendance/*` |
|
||||||
|
| 会议 | `/v1/workbench/meetings*` |
|
||||||
|
| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` |
|
||||||
|
| 日报 | `/v1/workbench/reports/daily*` |
|
||||||
|
| 月报 | `/v1/workbench/reports/monthly*` |
|
||||||
|
| 总经理信箱 | `/v1/workbench/executive-mailbox/*` |
|
||||||
|
| 工作台业务事件 | 生产 Kafka 事件到 `im.workbench.event` |
|
||||||
|
|
||||||
|
### 2.2 im_server 继续负责
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 登录、注册、Token 签发 | C 端登录仍走 `im_server` |
|
||||||
|
| Redis 登录态写入 | 登录成功后写 `im:session:token:{sha256(token)}` |
|
||||||
|
| 文件上传 | 暂由 `/v0/file/u/` 处理,工作台只保存 `file_id` |
|
||||||
|
| WebSocket 实时下发 | 工作台事件由 `im_server` 消费后转 C 端通知 |
|
||||||
|
| IM 消息、群、好友、在线状态 | 仍全部留在 `im_server` |
|
||||||
|
|
||||||
|
## 3. 网关路由
|
||||||
|
|
||||||
|
客户端对外仍访问统一域名,由 Nginx/API Gateway 按路径分流:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/v0/channels -> im_server
|
||||||
|
/v0/file/u/ -> im_server
|
||||||
|
/v1/im/* -> im_server
|
||||||
|
/v1/workbench/* -> im_workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
测试环境域名:
|
||||||
|
|
||||||
|
| 域名 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `https://api.118im.com` | 后端 API,包含 `/v1/workbench/*` |
|
||||||
|
| `https://api-test.118im.com` | 测试 API 备用域名 |
|
||||||
|
| `https://web.118im.com` | Web 前端同源代理 |
|
||||||
|
|
||||||
|
注意:不要再让 `im_server` 代理工作台新接口。新接口应直接进入 `im_workbench`。
|
||||||
|
|
||||||
|
## 4. 请求鉴权
|
||||||
|
|
||||||
|
工作台接口和 IM 产品 HTTP 保持同一套公共 Header:
|
||||||
|
|
||||||
|
| Header | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Authorization` | 是 | `Bearer {token}` |
|
||||||
|
| `X-MeetingIM-APIKey` | 是 | 客户端 API Key |
|
||||||
|
| `X-Tenant-Code` | 是 | 当前租户编码,例如 `default` |
|
||||||
|
| `X-IM-Schema` | 是 | 当前固定 `1` |
|
||||||
|
| `X-Request-ID` | 建议必填 | 请求链路 ID |
|
||||||
|
| `Idempotency-Key` | 写接口必填 | 写操作幂等键 |
|
||||||
|
|
||||||
|
登录态 Redis Key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
im:session:token:{sha256(token)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis Value:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 1572927143048908800,
|
||||||
|
"tenant_id": 1,
|
||||||
|
"tenant_code": "default",
|
||||||
|
"device_id": "ios-device-1",
|
||||||
|
"auth_level": "auth",
|
||||||
|
"state": "active",
|
||||||
|
"issued_at": "2026-09-14T10:00:00Z",
|
||||||
|
"expires_at": "2026-09-14T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
鉴权流程:
|
||||||
|
|
||||||
|
1. `im_workbench` 校验 `X-MeetingIM-APIKey` 非空或命中配置白名单。
|
||||||
|
2. 从 `Authorization` 读取 Bearer Token。
|
||||||
|
3. 对 Token 做 `sha256`,查 Redis 登录态。
|
||||||
|
4. 校验 `state=active`、未过期。
|
||||||
|
5. 校验 Header 的 `X-Tenant-Code` 与登录态 `tenant_code` 一致。
|
||||||
|
|
||||||
|
测试环境当前可用 API Key 以部署文档为准,最近验证可用的是新测试环境文档中的 `TINODE_API_KEY`。
|
||||||
|
|
||||||
|
## 5. 测试环境配置
|
||||||
|
|
||||||
|
配置文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/youshunlai/go_workspace/im_workbench/configs/workbench.test.json
|
||||||
|
```
|
||||||
|
|
||||||
|
当前关键配置:
|
||||||
|
|
||||||
|
| 配置 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 服务监听 | `:6061` |
|
||||||
|
| MySQL | `mysql.118im.com:30306` |
|
||||||
|
| MySQL 业务库 | `tinode` |
|
||||||
|
| Redis 数据端口 | `redis.118im.com:30637` |
|
||||||
|
| Redis Sentinel | `redis.118im.com:30638` |
|
||||||
|
| Kafka Bootstrap | `kafka.118im.com:30835` |
|
||||||
|
| Kafka 协议 | `PLAINTEXT` |
|
||||||
|
| Kafka Topic | `im.workbench.event` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
1. Kafka 测试环境是明文直连,不配置 SASL/TLS。
|
||||||
|
2. `im.workbench.event` 已在新 Kafka 集群创建。
|
||||||
|
3. Redis 数据 NodePort 可能落到主节点或副本。服务正常读登录态没问题;如果本地要写临时测试 session,可能遇到 `READONLY`,需要通过真正主库入口或先走真实登录链路。
|
||||||
|
4. MySQL、Redis 密码不写入本文,按 `im_server/docs/problems/新测试环境部署与中间件连接信息.md` 或部署 Secret 获取。
|
||||||
|
|
||||||
|
## 6. 本地启动
|
||||||
|
|
||||||
|
本地开发配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
连接测试环境配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.test.json go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
如果只做本地接口调试,可以临时打开开发 Header 鉴权:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.test.json \
|
||||||
|
IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true \
|
||||||
|
go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
开发 Header:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-User-ID: 1572927143048908800
|
||||||
|
X-Tenant-ID: 1
|
||||||
|
X-Tenant-Code: default
|
||||||
|
X-MeetingIM-APIKey: x
|
||||||
|
X-IM-Schema: 1
|
||||||
|
X-Request-ID: {uuid}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true` 只能本地调试使用,测试/生产部署不要开启。
|
||||||
|
|
||||||
|
## 7. Kafka 事件
|
||||||
|
|
||||||
|
工作台写操作统一投递:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Topic: im.workbench.event
|
||||||
|
```
|
||||||
|
|
||||||
|
事件体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "wbe_xxx",
|
||||||
|
"trace_id": "request-id",
|
||||||
|
"tenant_id": 1,
|
||||||
|
"tenant_code": "default",
|
||||||
|
"actor_user_id": 42,
|
||||||
|
"module": "meeting",
|
||||||
|
"action": "meeting.created",
|
||||||
|
"resource_id": "mtg_xxx",
|
||||||
|
"target_user_ids": [42, 43],
|
||||||
|
"title": "会议通知",
|
||||||
|
"summary": "项目周会",
|
||||||
|
"occurred_at": "2026-09-14T06:00:00Z",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前事件范围:
|
||||||
|
|
||||||
|
| 模块 | Action | 触发场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| 考勤 | `attendance.record.updated` | 打卡成功 |
|
||||||
|
| 考勤 | `attendance.appeal.created` | 提交考勤申诉 |
|
||||||
|
| 会议 | `meeting.created` | 创建会议 |
|
||||||
|
| 会议 | `meeting.updated` | 修改会议 |
|
||||||
|
| 会议 | `meeting.canceled` | 取消会议 |
|
||||||
|
| 会议 | `meeting.response.updated` | 更新参会回执 |
|
||||||
|
| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 |
|
||||||
|
| 日报 | `daily_report.draft.created` | 创建日报草稿 |
|
||||||
|
| 日报 | `daily_report.draft.saved` | 保存日报草稿 |
|
||||||
|
| 日报 | `daily_report.submitted` | 提交日报 |
|
||||||
|
| 日报 | `daily_report.reminded` | 日报提醒 |
|
||||||
|
| 月报 | `monthly_report.draft.created` | 创建月报草稿 |
|
||||||
|
| 月报 | `monthly_report.draft.saved` | 保存月报草稿 |
|
||||||
|
| 月报 | `monthly_report.submitted` | 提交月报 |
|
||||||
|
| 月报 | `monthly_report.reopened` | 重开月报 |
|
||||||
|
| 信箱 | `mailbox.submitted` | 提交总经理信箱 |
|
||||||
|
|
||||||
|
已修复问题:`kafka.Writer` 设置了默认 Topic 后,单条 `kafka.Message` 不再重复设置 Topic,否则 `kafka-go` 会报 `Topic must not be specified for both Writer and Message`。
|
||||||
|
|
||||||
|
## 8. 数据库现状
|
||||||
|
|
||||||
|
当前数据库不迁移,`im_workbench` 仍连接租户库 `tinode`。
|
||||||
|
|
||||||
|
主要表:
|
||||||
|
|
||||||
|
| 表 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `im_workbench_apps` | 工作台模块 |
|
||||||
|
| `im_org_departments` | 组织部门 |
|
||||||
|
| `im_org_members` | 组织成员 |
|
||||||
|
| `im_org_visibility_overrides` | 组织可见范围 |
|
||||||
|
| `im_attendance_rules` | 考勤规则 |
|
||||||
|
| `im_attendance_shifts` | 考勤班次 |
|
||||||
|
| `im_attendance_records` | 考勤记录 |
|
||||||
|
| `im_attendance_checkins` | 打卡流水 |
|
||||||
|
| `im_attendance_appeals` | 考勤申诉 |
|
||||||
|
| `im_meetings` | 会议 |
|
||||||
|
| `im_meeting_participants` | 会议参与人 |
|
||||||
|
| `im_meeting_note_files` | 会议笔记文件引用 |
|
||||||
|
| `im_daily_reports` | 日报 |
|
||||||
|
| `im_monthly_reports` | 月报 |
|
||||||
|
| `im_mailbox_config` | 总经理信箱配置 |
|
||||||
|
| `im_mailbox_submissions` | 总经理信箱提交 |
|
||||||
|
|
||||||
|
已兼容当前表结构:
|
||||||
|
|
||||||
|
| 表 | 当前结构注意点 | 代码处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| `im_org_members` | 成员状态字段是 `employment_state` | 查询时返回为 `status` |
|
||||||
|
| `im_attendance_rules` | 没有 `appeal_deadline` 字段 | 查询返回 `NULL AS appeal_deadline` |
|
||||||
|
| `im_mailbox_config` | 没有 `version` 字段 | 用 `notice_updated_at` 派生版本 |
|
||||||
|
|
||||||
|
服务启动不自动建库建表,不执行 DDL。所有建表、改字段、初始化数据都走人工 SQL 审核和发布。
|
||||||
|
|
||||||
|
## 9. 已验证接口
|
||||||
|
|
||||||
|
使用新测试环境配置启动本地 `im_workbench`,并开启本地开发 Header 鉴权后,已验证:
|
||||||
|
|
||||||
|
| 接口 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /v1/workbench` | 200 |
|
||||||
|
| `GET /v1/workbench/organization/departments` | 200 |
|
||||||
|
| `GET /v1/workbench/attendance/config` | 200 |
|
||||||
|
| `GET /v1/workbench/executive-mailbox/config` | 200 |
|
||||||
|
| `POST /v1/workbench/executive-mailbox/submissions` | 200,Kafka 发布成功 |
|
||||||
|
|
||||||
|
同时执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
结果通过。
|
||||||
|
|
||||||
|
## 10. 新工作树接手步骤
|
||||||
|
|
||||||
|
1. 拉取 `im_workbench` 的 `dev` 分支。
|
||||||
|
2. 确认包含提交 `c1c1156` 或更新的提交。
|
||||||
|
3. 查看 `configs/workbench.test.json`,确认中间件地址是新测试环境域名,不是旧 VM `64.118.137.182`。
|
||||||
|
4. 执行 `go test ./...`。
|
||||||
|
5. 本地启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.test.json \
|
||||||
|
IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true \
|
||||||
|
go run ./cmd/workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 用开发 Header 调 `GET /v1/workbench`、组织、考勤、信箱配置。
|
||||||
|
7. 调一个写接口,例如信箱提交,确认返回 200,并在数据库清理测试数据。
|
||||||
|
8. 关闭开发 Header 鉴权,走真实登录 Token 验证网关链路。
|
||||||
|
9. 验证公网 `/v1/workbench/*` 路由已经进入 `im_workbench`,并确认网关透传 `Authorization`、`X-MeetingIM-APIKey`、`X-Tenant-Code`、`X-IM-Schema`、`X-Request-ID`。
|
||||||
|
|
||||||
|
## 11. 待后续处理
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 真实登录态联调 | 需要通过 `im_server` 登录后生成 Redis session,再从公网 `/v1/workbench/*` 验证 |
|
||||||
|
| 网关 Header 透传 | 必须确认 `Authorization` 没被 Nginx/API Gateway 清掉 |
|
||||||
|
| Redis 写入口 | 数据 NodePort 可能落副本,本地造 session 可能遇到 `READONLY` |
|
||||||
|
| im_server 消费工作台事件 | `im_server` 消费 `im.workbench.event` 后转系统通知/WS 下发需要继续联调 |
|
||||||
|
| 工作台数据库迁移 | 现阶段不迁,接口稳定后单独做工作台库迁移 |
|
||||||
|
| 删除 im_server 旧工作台代码 | 等新服务、网关、前端、通知链路全部稳定后再删 |
|
||||||
|
|
||||||
|
## 12. 常见问题
|
||||||
|
|
||||||
|
### 12.1 `/v1/workbench` 返回 `AUTH_REQUIRED`
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. 是否带 `Authorization: Bearer {token}`。
|
||||||
|
2. Redis 是否有 `im:session:token:{sha256(token)}`。
|
||||||
|
3. session 是否 `state=active` 且未过期。
|
||||||
|
4. `X-Tenant-Code` 是否和 session 中的 `tenant_code` 一致。
|
||||||
|
5. 网关是否透传 `Authorization`。
|
||||||
|
6. `im_workbench` 是否连到了和 `im_server` 相同的 Redis。
|
||||||
|
|
||||||
|
### 12.2 写接口返回 `workbench event publish failed`
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. Kafka broker 是否为 `kafka.118im.com:30835`。
|
||||||
|
2. Kafka 是否为 `PLAINTEXT`,不要配置 SASL。
|
||||||
|
3. Topic `im.workbench.event` 是否存在。
|
||||||
|
4. 服务代码是否已经包含 Kafka 双 Topic 修复。
|
||||||
|
|
||||||
|
### 12.3 组织或考勤接口 500
|
||||||
|
|
||||||
|
优先检查当前表结构是否和代码兼容:
|
||||||
|
|
||||||
|
1. `im_org_members` 使用 `employment_state`,不是 `status`。
|
||||||
|
2. `im_attendance_rules` 当前没有 `appeal_deadline`。
|
||||||
|
3. `im_mailbox_config` 当前没有 `version`。
|
||||||
|
|
||||||
|
这些兼容已在当前代码中处理;如果新工作树仍 500,先确认是否拉到最新提交。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 工作台服务迁移说明
|
||||||
|
|
||||||
|
本仓库承接 `im_server` 中的工作台接口,第一阶段目标是让工作台服务独立编译、独立启动、独立鉴权,并由网关把 `/v1/workbench/*` 分流到本服务。
|
||||||
|
|
||||||
|
## 已迁移接口
|
||||||
|
|
||||||
|
| 模块 | 新路径 |
|
||||||
|
|---|---|
|
||||||
|
| 工作台入口 | `GET /v1/workbench` |
|
||||||
|
| 组织架构 | `/v1/workbench/organization/*` |
|
||||||
|
| 考勤 | `/v1/workbench/attendance/*` |
|
||||||
|
| 会议 | `/v1/workbench/meetings*` |
|
||||||
|
| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` |
|
||||||
|
| 日报 | `/v1/workbench/reports/daily*` |
|
||||||
|
| 月报 | `/v1/workbench/reports/monthly*` |
|
||||||
|
| 总经理信箱 | `/v1/workbench/executive-mailbox/*` |
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
1. 数据库暂不迁移,仍访问现有租户库工作台表。
|
||||||
|
2. 文件上传仍由 `im_server` 的 `/v0/file/u/` 处理,工作台只保存 `file_id` 引用。
|
||||||
|
3. C 端实时通知仍由 `im_server` 下发;工作台后续只投 Kafka 业务事件。
|
||||||
|
4. `im_server` 原工作台代码暂不删除,待本服务联调稳定后再清理。
|
||||||
|
5. 服务启动不自动创建或修改表结构。
|
||||||
|
|
||||||
|
## 待联调项
|
||||||
|
|
||||||
|
1. `im_server` 登录后写入 Redis session:`im:session:token:{sha256(token)}`。
|
||||||
|
2. Nginx/API Gateway 路由:`/v1/workbench/* -> im_workbench:6061`。
|
||||||
|
3. 工作台业务事件 Kafka 化:会议、考勤、日报、月报、信箱事件投递给 `im_server`。
|
||||||
|
4. 数据库专项迁移:接口稳定后再从租户库拆到工作台库。
|
||||||
|
|
||||||
|
## Kafka 事件范围
|
||||||
|
|
||||||
|
当前工作台服务生产端统一投递到 `im.workbench.event`,由 `im_server` 后续接入消费组并转为系统通知、未读数或 WS 实时下发。
|
||||||
|
|
||||||
|
| 模块 | Action | 触发场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| 考勤 | `attendance.record.updated` | 打卡成功 |
|
||||||
|
| 考勤 | `attendance.appeal.created` | 提交考勤申诉 |
|
||||||
|
| 会议 | `meeting.created` | 创建会议 |
|
||||||
|
| 会议 | `meeting.updated` | 修改会议 |
|
||||||
|
| 会议 | `meeting.canceled` | 取消会议 |
|
||||||
|
| 会议 | `meeting.response.updated` | 参会人更新回执 |
|
||||||
|
| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 |
|
||||||
|
| 日报 | `daily_report.draft.created` | 创建日报草稿 |
|
||||||
|
| 日报 | `daily_report.draft.saved` | 保存日报草稿 |
|
||||||
|
| 日报 | `daily_report.submitted` | 提交日报 |
|
||||||
|
| 日报 | `daily_report.reminded` | 日报提醒 |
|
||||||
|
| 月报 | `monthly_report.draft.created` | 创建月报草稿 |
|
||||||
|
| 月报 | `monthly_report.draft.saved` | 保存月报草稿 |
|
||||||
|
| 月报 | `monthly_report.submitted` | 提交月报 |
|
||||||
|
| 月报 | `monthly_report.reopened` | 重开月报 |
|
||||||
|
| 信箱 | `mailbox.submitted` | 提交总经理信箱 |
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
module git.118im.com/im_team/im_workbench
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/redis/go-redis/v9 v9.12.1
|
||||||
|
github.com/segmentio/kafka-go v0.4.51
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
|
||||||
|
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
|
||||||
|
github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
|
||||||
|
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotConfigured = errors.New("ai provider is not configured")
|
||||||
|
ErrProvider = errors.New("ai provider failed")
|
||||||
|
ErrTimeout = errors.New("ai provider timeout")
|
||||||
|
ErrBadOutput = errors.New("ai provider returned invalid output")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg config.AIConfig
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateRequest struct {
|
||||||
|
Feature string
|
||||||
|
Model string
|
||||||
|
Messages []Message
|
||||||
|
Temperature float64
|
||||||
|
MaxTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateResponse struct {
|
||||||
|
ProviderRequestID string
|
||||||
|
Model string
|
||||||
|
Content string
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
FinishReason string
|
||||||
|
LatencyMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg config.AIConfig) *Client {
|
||||||
|
timeout := time.Duration(cfg.DeepSeek.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, httpClient: &http.Client{Timeout: timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
return c != nil && strings.TrimSpace(c.cfg.DeepSeek.APIKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DefaultModel() string {
|
||||||
|
if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" {
|
||||||
|
return "deepseek-flash"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.cfg.DefaultModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ComplexModel() string {
|
||||||
|
if c == nil || strings.TrimSpace(c.cfg.ComplexModel) == "" {
|
||||||
|
return c.DefaultModel()
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.cfg.ComplexModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
|
||||||
|
if c == nil || strings.TrimSpace(c.cfg.DeepSeek.APIKey) == "" {
|
||||||
|
return GenerateResponse{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(req.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = c.DefaultModel()
|
||||||
|
}
|
||||||
|
temperature := req.Temperature
|
||||||
|
if temperature == 0 {
|
||||||
|
temperature = c.cfg.Temperature
|
||||||
|
}
|
||||||
|
if temperature == 0 {
|
||||||
|
temperature = 0.2
|
||||||
|
}
|
||||||
|
maxTokens := req.MaxTokens
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = c.cfg.MaxOutputTokens
|
||||||
|
}
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 4096
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": maxTokens,
|
||||||
|
"stream": false,
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return GenerateResponse{}, err
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimRight(firstNonEmpty(c.cfg.DeepSeek.BaseURL, "https://api.deepseek.com"), "/")
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return GenerateResponse{}, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.DeepSeek.APIKey))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return GenerateResponse{}, ErrTimeout
|
||||||
|
}
|
||||||
|
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrProvider, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
|
||||||
|
return GenerateResponse{}, ErrTimeout
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return GenerateResponse{}, fmt.Errorf("%w: deepseek status %d: %s", ErrProvider, resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
var parsed chatCompletionResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||||
|
}
|
||||||
|
if len(parsed.Choices) == 0 {
|
||||||
|
return GenerateResponse{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
content := parsed.Choices[0].Message.Content
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return GenerateResponse{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
return GenerateResponse{
|
||||||
|
ProviderRequestID: parsed.ID,
|
||||||
|
Model: firstNonEmpty(parsed.Model, model),
|
||||||
|
Content: content,
|
||||||
|
PromptTokens: parsed.Usage.PromptTokens,
|
||||||
|
CompletionTokens: parsed.Usage.CompletionTokens,
|
||||||
|
TotalTokens: parsed.Usage.TotalTokens,
|
||||||
|
FinishReason: parsed.Choices[0].FinishReason,
|
||||||
|
LatencyMS: time.Since(start).Milliseconds(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractJSONObject(raw string) string {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if strings.HasPrefix(text, "```") {
|
||||||
|
text = strings.TrimPrefix(text, "```json")
|
||||||
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
text = strings.TrimSuffix(text, "```")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
start := strings.Index(text, "{")
|
||||||
|
end := strings.LastIndex(text, "}")
|
||||||
|
if start >= 0 && end >= start {
|
||||||
|
return text[start : end+1]
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatCompletionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Usage struct {
|
||||||
|
ProviderRequestID string
|
||||||
|
Model string
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
LatencyMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolishResult struct {
|
||||||
|
Text string
|
||||||
|
Usage Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentResult struct {
|
||||||
|
Title string
|
||||||
|
ContentMarkdown string
|
||||||
|
ContentPlain string
|
||||||
|
Sections []DocumentSection
|
||||||
|
Summary string
|
||||||
|
Usage Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentSection struct {
|
||||||
|
Heading string `json:"heading"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableAnalysisInput struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileType string `json:"file_type"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Profile TableProfile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableAnalysisResult struct {
|
||||||
|
Title string
|
||||||
|
Metrics []TableMetric
|
||||||
|
Preview TablePreview
|
||||||
|
Alerts []TableAlert
|
||||||
|
Interpretation string
|
||||||
|
Summary string
|
||||||
|
Usage Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableMetric struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
ChangeText string `json:"change_text"`
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TablePreview struct {
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Rows [][]string `json:"rows"`
|
||||||
|
TotalRows int `json:"total_rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableAlert struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableProfile struct {
|
||||||
|
Columns any `json:"columns,omitempty"`
|
||||||
|
SampleRows any `json:"sample_rows,omitempty"`
|
||||||
|
RowCount int `json:"row_count,omitempty"`
|
||||||
|
Stats any `json:"stats,omitempty"`
|
||||||
|
Outliers any `json:"outliers,omitempty"`
|
||||||
|
Parsed bool `json:"parsed"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingSummaryInput struct {
|
||||||
|
MeetingTitle string
|
||||||
|
StartAt string
|
||||||
|
EndAt string
|
||||||
|
Participants []string
|
||||||
|
Files []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingSummaryResult struct {
|
||||||
|
Conclusions []string
|
||||||
|
Decisions []string
|
||||||
|
ActionItems []MeetingActionItem
|
||||||
|
Usage Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingActionItem struct {
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
DueText string `json:"due_text"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(client *Client) *Service {
|
||||||
|
return &Service{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Configured() bool {
|
||||||
|
return s != nil && s.client != nil && s.client.Configured()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) {
|
||||||
|
if s == nil || s.client == nil {
|
||||||
|
return PolishResult{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
if maxOutputChars <= 0 {
|
||||||
|
maxOutputChars = 300
|
||||||
|
}
|
||||||
|
system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。"
|
||||||
|
user := fmt.Sprintf(`请润色下面的中文工作文案。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 语气:%s。
|
||||||
|
2. 场景:%s。
|
||||||
|
3. 输出不超过 %d 个中文字符。
|
||||||
|
4. 保持事实不变,不编造数据、人名、时间和结论。
|
||||||
|
5. 只输出 JSON:{"polished_text":"..."}。
|
||||||
|
|
||||||
|
原文:
|
||||||
|
%s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText)
|
||||||
|
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||||
|
Feature: "text_polish",
|
||||||
|
Model: s.client.DefaultModel(),
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
MaxTokens: maxOutputChars * 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PolishResult{}, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
PolishedText string `json:"polished_text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||||
|
return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||||
|
}
|
||||||
|
out.PolishedText = strings.TrimSpace(out.PolishedText)
|
||||||
|
if out.PolishedText == "" {
|
||||||
|
return PolishResult{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) {
|
||||||
|
if s == nil || s.client == nil {
|
||||||
|
return DocumentResult{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||||
|
user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 输出语言:%s。
|
||||||
|
2. 文档类型:%s。
|
||||||
|
3. 标题提示:%s。
|
||||||
|
4. 内容要忠于原文,不编造事实。
|
||||||
|
5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。
|
||||||
|
6. content_markdown 要适合直接展示,使用二级标题和列表。
|
||||||
|
7. 只输出 JSON,格式:
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"summary": "string",
|
||||||
|
"content_markdown": "string",
|
||||||
|
"content_plain": "string",
|
||||||
|
"sections": [{"heading":"string","content":"string"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
用户输入:
|
||||||
|
%s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText)
|
||||||
|
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||||
|
Feature: "smart_document",
|
||||||
|
Model: s.client.DefaultModel(),
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
MaxTokens: 2400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return DocumentResult{}, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ContentMarkdown string `json:"content_markdown"`
|
||||||
|
ContentPlain string `json:"content_plain"`
|
||||||
|
Sections []DocumentSection `json:"sections"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||||
|
return DocumentResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||||
|
}
|
||||||
|
out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档"))
|
||||||
|
out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown)
|
||||||
|
if out.ContentMarkdown == "" && len(out.Sections) > 0 {
|
||||||
|
out.ContentMarkdown = markdownFromSections(out.Sections)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.ContentPlain) == "" {
|
||||||
|
out.ContentPlain = plainFromSections(out.Sections)
|
||||||
|
}
|
||||||
|
if out.ContentMarkdown == "" {
|
||||||
|
return DocumentResult{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
return DocumentResult{
|
||||||
|
Title: out.Title,
|
||||||
|
ContentMarkdown: out.ContentMarkdown,
|
||||||
|
ContentPlain: strings.TrimSpace(out.ContentPlain),
|
||||||
|
Sections: out.Sections,
|
||||||
|
Summary: strings.TrimSpace(out.Summary),
|
||||||
|
Usage: usageFromResponse(resp),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) {
|
||||||
|
if s == nil || s.client == nil {
|
||||||
|
return TableAnalysisResult{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||||
|
profileJSON, _ := json.Marshal(input.Profile)
|
||||||
|
user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。
|
||||||
|
2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。
|
||||||
|
|
||||||
|
报表主题:%s
|
||||||
|
文件名:%s
|
||||||
|
文件类型:%s
|
||||||
|
文件大小:%d bytes
|
||||||
|
表格画像:%s
|
||||||
|
|
||||||
|
只输出 JSON:
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"summary": "string",
|
||||||
|
"metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}],
|
||||||
|
"preview": {"columns":["string"],"rows":[["string"]],"total_rows":0},
|
||||||
|
"alerts": [{"level":"info|warning|danger","title":"string","description":"string"}],
|
||||||
|
"interpretation": "string"
|
||||||
|
}`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON))
|
||||||
|
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||||
|
Feature: "smart_table",
|
||||||
|
Model: s.client.ComplexModel(),
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
MaxTokens: 1800,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TableAnalysisResult{}, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Metrics []TableMetric `json:"metrics"`
|
||||||
|
Preview TablePreview `json:"preview"`
|
||||||
|
Alerts []TableAlert `json:"alerts"`
|
||||||
|
Interpretation string `json:"interpretation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||||
|
return TableAnalysisResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||||
|
}
|
||||||
|
out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告")
|
||||||
|
if strings.TrimSpace(out.Interpretation) == "" {
|
||||||
|
return TableAnalysisResult{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
return TableAnalysisResult{
|
||||||
|
Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts,
|
||||||
|
Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usageFromResponse(resp),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) {
|
||||||
|
if s == nil || s.client == nil {
|
||||||
|
return MeetingSummaryResult{}, ErrNotConfigured
|
||||||
|
}
|
||||||
|
system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||||
|
payload, _ := json.Marshal(input)
|
||||||
|
user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 不编造会议中没有出现的事实。
|
||||||
|
2. 若附件内容不足,请基于标题和上下文给出谨慎总结。
|
||||||
|
3. action_items 的 status 固定为 pending。
|
||||||
|
4. 只输出 JSON:
|
||||||
|
{
|
||||||
|
"conclusions": ["string"],
|
||||||
|
"decisions": ["string"],
|
||||||
|
"action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
会议上下文:
|
||||||
|
%s`, string(payload))
|
||||||
|
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||||
|
Feature: "meeting_summary",
|
||||||
|
Model: s.client.ComplexModel(),
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
MaxTokens: 1800,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return MeetingSummaryResult{}, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Conclusions []string `json:"conclusions"`
|
||||||
|
Decisions []string `json:"decisions"`
|
||||||
|
ActionItems []MeetingActionItem `json:"action_items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||||
|
return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||||
|
}
|
||||||
|
if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 {
|
||||||
|
return MeetingSummaryResult{}, ErrBadOutput
|
||||||
|
}
|
||||||
|
for i := range out.ActionItems {
|
||||||
|
if strings.TrimSpace(out.ActionItems[i].Status) == "" {
|
||||||
|
out.ActionItems[i].Status = "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromResponse(resp GenerateResponse) Usage {
|
||||||
|
return Usage{
|
||||||
|
ProviderRequestID: resp.ProviderRequestID,
|
||||||
|
Model: resp.Model,
|
||||||
|
PromptTokens: resp.PromptTokens,
|
||||||
|
CompletionTokens: resp.CompletionTokens,
|
||||||
|
TotalTokens: resp.TotalTokens,
|
||||||
|
LatencyMS: resp.LatencyMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownFromSections(sections []DocumentSection) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, section := range sections {
|
||||||
|
heading := strings.TrimSpace(section.Heading)
|
||||||
|
content := strings.TrimSpace(section.Content)
|
||||||
|
if heading == "" && content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if heading != "" {
|
||||||
|
b.WriteString("## ")
|
||||||
|
b.WriteString(heading)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainFromSections(sections []DocumentSection) string {
|
||||||
|
parts := make([]string, 0, len(sections)*2)
|
||||||
|
for _, section := range sections {
|
||||||
|
if heading := strings.TrimSpace(section.Heading); heading != "" {
|
||||||
|
parts = append(parts, heading)
|
||||||
|
}
|
||||||
|
if content := strings.TrimSpace(section.Content); content != "" {
|
||||||
|
parts = append(parts, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/ai"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/events"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/httpapi"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/managerconfig"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
store *store.Store
|
||||||
|
redis *redis.Client
|
||||||
|
events events.Publisher
|
||||||
|
api *httpapi.API
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
uidKey := managerconfig.ResolveUIDKey(context.Background(), cfg.Manager, cfg.Auth.UIDKey)
|
||||||
|
if err := httpapi.SetUIDKey(uidKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := store.SetUIDKey(uidKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st, err := store.Open(cfg.MySQL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var redisClient *redis.Client
|
||||||
|
if !cfg.Auth.SkipSessionInDev {
|
||||||
|
redisClient = redis.NewClient(&redis.Options{
|
||||||
|
Addr: cfg.Redis.Addr,
|
||||||
|
Username: cfg.Redis.Username,
|
||||||
|
Password: cfg.Redis.Password,
|
||||||
|
DB: cfg.Redis.DB,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||||
|
_ = st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publisher, err := events.NewKafkaPublisher(cfg.Kafka)
|
||||||
|
if err != nil {
|
||||||
|
if redisClient != nil {
|
||||||
|
_ = redisClient.Close()
|
||||||
|
}
|
||||||
|
_ = st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aiService := ai.NewService(ai.NewClient(cfg.AI))
|
||||||
|
return &App{
|
||||||
|
store: st,
|
||||||
|
redis: redisClient,
|
||||||
|
events: publisher,
|
||||||
|
api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth, aiService),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
a.api.Register(mux)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Close() {
|
||||||
|
if a.redis != nil {
|
||||||
|
_ = a.redis.Close()
|
||||||
|
}
|
||||||
|
if a.events != nil {
|
||||||
|
_ = a.events.Close()
|
||||||
|
}
|
||||||
|
if a.store != nil {
|
||||||
|
_ = a.store.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthRequired = errors.New("authentication required")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
TenantID int64
|
||||||
|
TenantCode string
|
||||||
|
UserID int64
|
||||||
|
DeviceID string
|
||||||
|
AuthLevel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
TenantCode string `json:"tenant_code"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
AuthLevel string `json:"auth_level"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
IssuedAt string `json:"issued_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Authenticator struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
||||||
|
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||||
|
if err := a.validateAPIKey(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.cfg.AllowDevHeaderAuth {
|
||||||
|
if principal := principalFromDevHeaders(req); principal != nil {
|
||||||
|
return principal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.cfg.SkipSessionInDev {
|
||||||
|
return &Principal{
|
||||||
|
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||||
|
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||||
|
UserID: parseInt64Header(req, "X-User-ID", 1),
|
||||||
|
AuthLevel: "auth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if a.redis == nil {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
token, err := bearerToken(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
session, err := a.sessionFromRedis(req.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if session.UserID <= 0 || session.TenantID <= 0 {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if state := strings.ToLower(strings.TrimSpace(session.State)); state != "" && state != "active" {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if session.ExpiresAt != "" {
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
|
||||||
|
if err == nil && time.Now().After(expiresAt) {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headerTenant := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
||||||
|
if a.cfg.RequiredTenantCode && headerTenant == "" {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
if headerTenant != "" && session.TenantCode != "" && headerTenant != session.TenantCode {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
return &Principal{
|
||||||
|
TenantID: session.TenantID,
|
||||||
|
TenantCode: firstNonEmpty(session.TenantCode, headerTenant),
|
||||||
|
UserID: session.UserID,
|
||||||
|
DeviceID: session.DeviceID,
|
||||||
|
AuthLevel: session.AuthLevel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) validateAPIKey(req *http.Request) error {
|
||||||
|
if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(req.Header.Get("X-MeetingIM-APIKey"))
|
||||||
|
if got == "" {
|
||||||
|
got = strings.TrimSpace(req.URL.Query().Get("apikey"))
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
if a.cfg.RequiredAPIKey {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(a.cfg.APIKeys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range a.cfg.APIKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) {
|
||||||
|
secret := []byte(strings.TrimSpace(token))
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
|
||||||
|
secret = decoded
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(secret)
|
||||||
|
key := fmt.Sprintf(a.cfg.SessionKeyPattern, hex.EncodeToString(digest[:]))
|
||||||
|
data, err := a.redis.Get(ctx, key).Bytes()
|
||||||
|
if err != nil && errors.Is(err, redis.Nil) {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var session Session
|
||||||
|
if err := json.Unmarshal(data, &session); err != nil {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(req *http.Request) (string, error) {
|
||||||
|
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||||
|
token, ok := strings.CutPrefix(authz, "Bearer ")
|
||||||
|
if !ok || strings.TrimSpace(token) == "" {
|
||||||
|
return "", ErrAuthRequired
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromDevHeaders(req *http.Request) *Principal {
|
||||||
|
uid := parseInt64Header(req, "X-User-ID", 0)
|
||||||
|
if uid <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Principal{
|
||||||
|
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||||
|
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||||
|
UserID: uid,
|
||||||
|
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID")),
|
||||||
|
AuthLevel: "auth",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64Header(req *http.Request, key string, fallback int64) int64 {
|
||||||
|
value := strings.TrimSpace(req.Header.Get(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
MySQL MySQLConfig `json:"mysql"`
|
||||||
|
Redis RedisConfig `json:"redis"`
|
||||||
|
Kafka KafkaConfig `json:"kafka"`
|
||||||
|
Auth AuthConfig `json:"auth"`
|
||||||
|
Manager ManagerConfig `json:"manager"`
|
||||||
|
AI AIConfig `json:"ai"`
|
||||||
|
ReadTimeout time.Duration `json:"-"`
|
||||||
|
WriteTimeout time.Duration `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MySQLConfig struct {
|
||||||
|
DSN string `json:"dsn"`
|
||||||
|
MaxOpenConns int `json:"max_open_conns"`
|
||||||
|
MaxIdleConns int `json:"max_idle_conns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DB int `json:"db"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KafkaConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Brokers []string `json:"brokers"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SASL string `json:"sasl"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
BatchSize int `json:"batch_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
APIKeys []string `json:"api_keys"`
|
||||||
|
SessionKeyPattern string `json:"session_key_pattern"`
|
||||||
|
UIDKey string `json:"uid_key"`
|
||||||
|
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||||
|
RequiredTenantCode bool `json:"required_tenant_code"`
|
||||||
|
RequiredAPIKey bool `json:"required_api_key"`
|
||||||
|
SkipSessionInDev bool `json:"skip_session_in_dev"`
|
||||||
|
TrustedInternalAddr string `json:"trusted_internal_addr"`
|
||||||
|
InternalServiceName string `json:"internal_service_name"`
|
||||||
|
InternalSecret string `json:"internal_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagerConfig struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
UIDKeyGroup string `json:"uid_key_group"`
|
||||||
|
UIDKeyKeys []string `json:"uid_key_keys"`
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfig struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
DeepSeek DeepSeekConfig `json:"deepseek"`
|
||||||
|
DefaultModel string `json:"default_model"`
|
||||||
|
ComplexModel string `json:"complex_model"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxOutputTokens int `json:"max_output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeepSeekConfig struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
MaxRetries int `json:"max_retries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyEnv(cfg)
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Listen: ":6061",
|
||||||
|
MySQL: MySQLConfig{
|
||||||
|
DSN: "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local",
|
||||||
|
MaxOpenConns: 15,
|
||||||
|
MaxIdleConns: 5,
|
||||||
|
},
|
||||||
|
Redis: RedisConfig{
|
||||||
|
Addr: "127.0.0.1:6379",
|
||||||
|
KeyPrefix: "im:",
|
||||||
|
},
|
||||||
|
Kafka: KafkaConfig{
|
||||||
|
Topic: "im.workbench.event",
|
||||||
|
ClientID: "im_workbench",
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
SessionKeyPattern: "im:session:token:%s",
|
||||||
|
UIDKey: "dkXxWLwqUNRfYmdXRzOkZw==",
|
||||||
|
RequiredTenantCode: true,
|
||||||
|
RequiredAPIKey: true,
|
||||||
|
},
|
||||||
|
Manager: ManagerConfig{
|
||||||
|
ServiceName: "im-workbench",
|
||||||
|
UIDKeyGroup: "im",
|
||||||
|
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
||||||
|
TimeoutMS: 3000,
|
||||||
|
},
|
||||||
|
AI: AIConfig{
|
||||||
|
Provider: "deepseek",
|
||||||
|
DefaultModel: "deepseek-flash",
|
||||||
|
ComplexModel: "deepseek-v4-pro",
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxOutputTokens: 4096,
|
||||||
|
DeepSeek: DeepSeekConfig{
|
||||||
|
BaseURL: "https://api.deepseek.com",
|
||||||
|
TimeoutMS: 60000,
|
||||||
|
MaxRetries: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ReadTimeout: 8 * time.Second,
|
||||||
|
WriteTimeout: 8 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnv(cfg *Config) {
|
||||||
|
setString(&cfg.Listen, "IM_WORKBENCH_LISTEN")
|
||||||
|
setString(&cfg.MySQL.DSN, "IM_WORKBENCH_MYSQL_DSN")
|
||||||
|
setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
||||||
|
setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
||||||
|
setString(&cfg.Redis.Addr, "IM_WORKBENCH_REDIS_ADDR")
|
||||||
|
setString(&cfg.Redis.Username, "IM_WORKBENCH_REDIS_USERNAME")
|
||||||
|
setString(&cfg.Redis.Password, "IM_WORKBENCH_REDIS_PASSWORD")
|
||||||
|
setString(&cfg.Redis.KeyPrefix, "IM_WORKBENCH_REDIS_KEY_PREFIX")
|
||||||
|
setString(&cfg.Kafka.Topic, "IM_WORKBENCH_KAFKA_TOPIC")
|
||||||
|
setString(&cfg.Kafka.Username, "IM_WORKBENCH_KAFKA_USERNAME")
|
||||||
|
setString(&cfg.Kafka.Password, "IM_WORKBENCH_KAFKA_PASSWORD")
|
||||||
|
setString(&cfg.Kafka.SASL, "IM_WORKBENCH_KAFKA_SASL")
|
||||||
|
setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID")
|
||||||
|
setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN")
|
||||||
|
setString(&cfg.Auth.UIDKey, "IM_WORKBENCH_UID_KEY")
|
||||||
|
setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR")
|
||||||
|
setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME")
|
||||||
|
setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET")
|
||||||
|
setString(&cfg.Manager.BaseURL, "IM_WORKBENCH_MANAGER_BASE_URL")
|
||||||
|
setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME")
|
||||||
|
setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET")
|
||||||
|
setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP")
|
||||||
|
setString(&cfg.AI.Provider, "IM_WORKBENCH_AI_PROVIDER")
|
||||||
|
setString(&cfg.AI.DeepSeek.BaseURL, "DEEPSEEK_BASE_URL")
|
||||||
|
setString(&cfg.AI.DeepSeek.APIKey, "DEEPSEEK_API_KEY")
|
||||||
|
setString(&cfg.AI.DefaultModel, "DEEPSEEK_DEFAULT_MODEL")
|
||||||
|
setString(&cfg.AI.ComplexModel, "DEEPSEEK_COMPLEX_MODEL")
|
||||||
|
setInt(&cfg.AI.DeepSeek.TimeoutMS, "DEEPSEEK_TIMEOUT_MS")
|
||||||
|
setInt(&cfg.AI.DeepSeek.MaxRetries, "DEEPSEEK_MAX_RETRIES")
|
||||||
|
setInt(&cfg.AI.MaxOutputTokens, "DEEPSEEK_MAX_OUTPUT_TOKENS")
|
||||||
|
setFloat(&cfg.AI.Temperature, "DEEPSEEK_TEMPERATURE")
|
||||||
|
if strings.TrimSpace(cfg.Auth.InternalSecret) == "" {
|
||||||
|
setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Auth.InternalSecret) == "" {
|
||||||
|
setString(&cfg.Auth.InternalSecret, "IM_ADMIN_API_SECRET")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Manager.Secret) == "" {
|
||||||
|
setString(&cfg.Manager.Secret, "IM_CONFIG_API_SECRET")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Manager.Secret) == "" {
|
||||||
|
setString(&cfg.Manager.Secret, "IM_INTERNAL_API_SECRET")
|
||||||
|
}
|
||||||
|
if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" {
|
||||||
|
cfg.Kafka.Brokers = splitCSV(brokers)
|
||||||
|
}
|
||||||
|
if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_API_KEYS")); keys != "" {
|
||||||
|
cfg.Auth.APIKeys = splitCSV(keys)
|
||||||
|
}
|
||||||
|
if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_UID_KEY_KEYS")); keys != "" {
|
||||||
|
cfg.Manager.UIDKeyKeys = splitCSV(keys)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BATCH_SIZE")); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.Kafka.BatchSize = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_TIMEOUT_MS")); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.Manager.TimeoutMS = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_REDIS_DB")); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.Redis.DB = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBool(&cfg.Auth.AllowDevHeaderAuth, "IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
||||||
|
setBool(&cfg.Auth.RequiredTenantCode, "IM_WORKBENCH_REQUIRED_TENANT_CODE")
|
||||||
|
setBool(&cfg.Auth.RequiredAPIKey, "IM_WORKBENCH_REQUIRED_API_KEY")
|
||||||
|
setBool(&cfg.Auth.SkipSessionInDev, "IM_WORKBENCH_SKIP_SESSION_IN_DEV")
|
||||||
|
setBool(&cfg.Kafka.Enabled, "IM_WORKBENCH_KAFKA_ENABLED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if strings.TrimSpace(c.Listen) == "" {
|
||||||
|
return errors.New("listen is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.MySQL.DSN) == "" {
|
||||||
|
return errors.New("mysql dsn is required")
|
||||||
|
}
|
||||||
|
mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://")
|
||||||
|
parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mysql dsn is invalid: %w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") {
|
||||||
|
return errors.New("mysql database tinode is deprecated; use workbench")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev {
|
||||||
|
return errors.New("redis addr is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Auth.SessionKeyPattern) == "" {
|
||||||
|
return errors.New("session key pattern is required")
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
|
||||||
|
return fmt.Errorf("session key pattern must include %%s")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.AI.Provider) == "" {
|
||||||
|
c.AI.Provider = "deepseek"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.AI.DeepSeek.BaseURL) == "" {
|
||||||
|
c.AI.DeepSeek.BaseURL = "https://api.deepseek.com"
|
||||||
|
}
|
||||||
|
if c.AI.DeepSeek.TimeoutMS <= 0 {
|
||||||
|
c.AI.DeepSeek.TimeoutMS = 60000
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.AI.DefaultModel) == "" {
|
||||||
|
c.AI.DefaultModel = "deepseek-flash"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.AI.ComplexModel) == "" {
|
||||||
|
c.AI.ComplexModel = c.AI.DefaultModel
|
||||||
|
}
|
||||||
|
if c.AI.Temperature == 0 {
|
||||||
|
c.AI.Temperature = 0.2
|
||||||
|
}
|
||||||
|
if c.AI.MaxOutputTokens <= 0 {
|
||||||
|
c.AI.MaxOutputTokens = 4096
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setString(target *string, key string) {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
*target = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInt(target *int, key string) {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
*target = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(target *bool, key string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
*target = true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
*target = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFloat(target *float64, key string) {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
*target = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
items := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if item := strings.TrimSpace(part); item != "" {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true"
|
||||||
|
|
||||||
|
err := cfg.validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") {
|
||||||
|
t.Fatalf("validate() error = %v, want deprecated database error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfigUsesWorkbenchDatabase(t *testing.T) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
t.Fatalf("default config validation failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg.MySQL.DSN, "/workbench?") {
|
||||||
|
t.Fatalf("default mysql dsn = %q, want workbench database", cfg.MySQL.DSN)
|
||||||
|
}
|
||||||
|
if cfg.MySQL.MaxOpenConns != 15 || cfg.MySQL.MaxIdleConns != 5 {
|
||||||
|
t.Fatalf("default mysql pool = %d/%d, want 15/5", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvOverridesMySQLPool(t *testing.T) {
|
||||||
|
t.Setenv("IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS", "12")
|
||||||
|
t.Setenv("IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS", "4")
|
||||||
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
applyEnv(cfg)
|
||||||
|
|
||||||
|
if cfg.MySQL.MaxOpenConns != 12 || cfg.MySQL.MaxIdleConns != 4 {
|
||||||
|
t.Fatalf("mysql pool = %d/%d, want 12/4", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
"github.com/segmentio/kafka-go"
|
||||||
|
"github.com/segmentio/kafka-go/sasl"
|
||||||
|
"github.com/segmentio/kafka-go/sasl/scram"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
TraceID string `json:"trace_id,omitempty"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
TenantCode string `json:"tenant_code,omitempty"`
|
||||||
|
ActorUserID int64 `json:"actor_user_id"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
TargetUserIDs []int64 `json:"target_user_ids,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
Data map[string]any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Publisher interface {
|
||||||
|
Publish(context.Context, Event) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoopPublisher struct{}
|
||||||
|
|
||||||
|
func (NoopPublisher) Publish(context.Context, Event) error { return nil }
|
||||||
|
func (NoopPublisher) Close() error { return nil }
|
||||||
|
|
||||||
|
type KafkaPublisher struct {
|
||||||
|
writer *kafka.Writer
|
||||||
|
topic string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKafkaPublisher(cfg config.KafkaConfig) (Publisher, error) {
|
||||||
|
if !cfg.Enabled || len(cfg.Brokers) == 0 {
|
||||||
|
return NoopPublisher{}, nil
|
||||||
|
}
|
||||||
|
topic := strings.TrimSpace(cfg.Topic)
|
||||||
|
if topic == "" {
|
||||||
|
topic = "im.workbench.event"
|
||||||
|
}
|
||||||
|
transport := &kafka.Transport{}
|
||||||
|
if mechanism, err := saslMechanism(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if mechanism != nil {
|
||||||
|
transport.SASL = mechanism
|
||||||
|
}
|
||||||
|
writer := &kafka.Writer{
|
||||||
|
Addr: kafka.TCP(cfg.Brokers...),
|
||||||
|
Topic: topic,
|
||||||
|
Balancer: &kafka.Hash{},
|
||||||
|
Transport: transport,
|
||||||
|
BatchSize: cfg.BatchSize,
|
||||||
|
RequiredAcks: kafka.RequireAll,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
if writer.BatchSize <= 0 {
|
||||||
|
writer.BatchSize = 1
|
||||||
|
}
|
||||||
|
return &KafkaPublisher{writer: writer, topic: topic}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KafkaPublisher) Publish(ctx context.Context, event Event) error {
|
||||||
|
if p == nil || p.writer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(event.EventID) == "" {
|
||||||
|
event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID)
|
||||||
|
}
|
||||||
|
if event.OccurredAt.IsZero() {
|
||||||
|
event.OccurredAt = time.Now().UTC().Round(time.Millisecond)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := []byte(event.TenantCode + ":" + event.ResourceID)
|
||||||
|
if len(key) == 1 {
|
||||||
|
key = []byte(event.EventID)
|
||||||
|
}
|
||||||
|
return p.writer.WriteMessages(ctx, kafka.Message{
|
||||||
|
Key: key,
|
||||||
|
Value: data,
|
||||||
|
Headers: []kafka.Header{
|
||||||
|
{Key: "event_id", Value: []byte(event.EventID)},
|
||||||
|
{Key: "event_type", Value: []byte(event.Action)},
|
||||||
|
{Key: "tenant_code", Value: []byte(event.TenantCode)},
|
||||||
|
},
|
||||||
|
Time: event.OccurredAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KafkaPublisher) Close() error {
|
||||||
|
if p == nil || p.writer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.writer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkbenchEvent(p *auth.Principal, traceID, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) Event {
|
||||||
|
event := Event{
|
||||||
|
TraceID: strings.TrimSpace(traceID),
|
||||||
|
TenantID: p.TenantID,
|
||||||
|
TenantCode: p.TenantCode,
|
||||||
|
ActorUserID: p.UserID,
|
||||||
|
Module: strings.TrimSpace(module),
|
||||||
|
Action: strings.TrimSpace(action),
|
||||||
|
ResourceID: strings.TrimSpace(resourceID),
|
||||||
|
TargetUserIDs: compactInt64s(targets),
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
Summary: strings.TrimSpace(summary),
|
||||||
|
OccurredAt: time.Now().UTC().Round(time.Millisecond),
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func StableEventID(tenantID int64, module, action, resourceID string, actorUserID int64) string {
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
int64String(tenantID),
|
||||||
|
strings.TrimSpace(module),
|
||||||
|
strings.TrimSpace(action),
|
||||||
|
strings.TrimSpace(resourceID),
|
||||||
|
int64String(actorUserID),
|
||||||
|
}, ":")
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return "wbe_" + hex.EncodeToString(sum[:])[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func saslMechanism(cfg config.KafkaConfig) (sasl.Mechanism, error) {
|
||||||
|
if strings.TrimSpace(cfg.Username) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(cfg.SASL)) {
|
||||||
|
case "", "SCRAM-SHA-512":
|
||||||
|
return scram.Mechanism(scram.SHA512, cfg.Username, cfg.Password)
|
||||||
|
case "SCRAM-SHA-256":
|
||||||
|
return scram.Mechanism(scram.SHA256, cfg.Username, cfg.Password)
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported kafka sasl mechanism")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactInt64s(values []int64) []int64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[int64]bool, len(values))
|
||||||
|
items := make([]int64, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value <= 0 || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
items = append(items, value)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64String(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
TenantCode string `json:"tenant_code"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TenantID <= 0 {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := st.InitializeTenantDefaults(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{
|
||||||
|
"tenant_id": body.TenantID,
|
||||||
|
"workbench_apps": result.WorkbenchApps,
|
||||||
|
"attendance_rules": result.AttendanceRules,
|
||||||
|
"attendance_shifts": result.AttendanceShifts,
|
||||||
|
"mailbox_configs": result.MailboxConfigs,
|
||||||
|
"feedback_configs": result.FeedbackConfigs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
page := queryInt(r, "page", 1)
|
||||||
|
perPage := queryInt(r, "per_page", 20)
|
||||||
|
var enabled *int
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" {
|
||||||
|
if value, err := strconv.Atoi(raw); err == nil {
|
||||||
|
enabled = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminWorkbenchModuleRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{
|
||||||
|
ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route,
|
||||||
|
SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminWorkbenchModulePayload(*item))
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items))
|
||||||
|
for _, item := range body.Items {
|
||||||
|
items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder})
|
||||||
|
}
|
||||||
|
if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := queryInt(r, "page", 1)
|
||||||
|
perPage := queryInt(r, "per_page", 20)
|
||||||
|
items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage,
|
||||||
|
r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state"))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := queryInt(r, "page", 1)
|
||||||
|
perPage := queryInt(r, "per_page", 20)
|
||||||
|
userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64)
|
||||||
|
items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage,
|
||||||
|
r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")),
|
||||||
|
r.URL.Query().Get("employment_state"), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
cfg, err := st.AdminMailboxConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminMailboxConfigPayload(*cfg))
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminMailboxConfigRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{
|
||||||
|
Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields,
|
||||||
|
MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent,
|
||||||
|
}); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := a.internalAdminStore(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
cfg, err := st.AdminFeedbackConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminFeedbackConfigPayload(*cfg))
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminFeedbackConfigRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{
|
||||||
|
Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy,
|
||||||
|
AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice,
|
||||||
|
}); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) {
|
||||||
|
tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64)
|
||||||
|
tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default")
|
||||||
|
st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return st, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminWorkbenchModuleRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IconFileID string `json:"icon_file_id"`
|
||||||
|
Route string `json:"route"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
UnreadKey string `json:"unread_key"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminMailboxConfigRequest struct {
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
AllowAnonymous int `json:"allow_anonymous"`
|
||||||
|
RequiredFields string `json:"required_fields"`
|
||||||
|
MaxLength int `json:"max_length"`
|
||||||
|
AttachmentPolicy string `json:"attachment_policy"`
|
||||||
|
NoticeTitle string `json:"notice_title"`
|
||||||
|
NoticeContent string `json:"notice_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminFeedbackConfigRequest struct {
|
||||||
|
Categories string `json:"categories"`
|
||||||
|
MaxLength int `json:"max_length"`
|
||||||
|
AttachmentPolicy string `json:"attachment_policy"`
|
||||||
|
AllowContact int `json:"allow_contact"`
|
||||||
|
DiagnosticsNotice string `json:"diagnostics_notice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any {
|
||||||
|
res := make([]map[string]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
res = append(res, adminWorkbenchModulePayload(item))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": item.ID,
|
||||||
|
"code": item.Code,
|
||||||
|
"name": item.Name,
|
||||||
|
"iconFileId": item.IconFileID,
|
||||||
|
"route": item.Route,
|
||||||
|
"sortOrder": item.SortOrder,
|
||||||
|
"unreadKey": item.UnreadKey,
|
||||||
|
"enabled": item.Enabled,
|
||||||
|
"permission": item.Permission,
|
||||||
|
"createdAt": formatAdminTime(item.CreatedAt),
|
||||||
|
"updatedAt": formatAdminTime(item.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any {
|
||||||
|
res := make([]map[string]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
res = append(res, map[string]any{
|
||||||
|
"department_id": item.DepartmentID,
|
||||||
|
"name": item.Name,
|
||||||
|
"parent_id": item.ParentID,
|
||||||
|
"child_count": item.ChildCount,
|
||||||
|
"member_count": item.MemberCount,
|
||||||
|
"version": item.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any {
|
||||||
|
res := make([]map[string]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
res = append(res, map[string]any{
|
||||||
|
"user_id": item.UserID,
|
||||||
|
"department_id": item.DepartmentID,
|
||||||
|
"display_name": item.DisplayName,
|
||||||
|
"position": item.Position,
|
||||||
|
"mobile": item.Mobile,
|
||||||
|
"email": item.Email,
|
||||||
|
"avatar_file_id": item.AvatarFileID,
|
||||||
|
"status": item.Status,
|
||||||
|
"contactable": item.Contactable,
|
||||||
|
"version": item.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"enabled": cfg.Enabled,
|
||||||
|
"allowAnonymous": cfg.AllowAnonymous,
|
||||||
|
"requiredFields": cfg.RequiredFields,
|
||||||
|
"maxLength": cfg.MaxLength,
|
||||||
|
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||||
|
"noticeTitle": cfg.NoticeTitle,
|
||||||
|
"noticeContent": cfg.NoticeContent,
|
||||||
|
"noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt),
|
||||||
|
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"categories": cfg.Categories,
|
||||||
|
"maxLength": cfg.MaxLength,
|
||||||
|
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||||
|
"allowContact": cfg.AllowContact,
|
||||||
|
"diagnosticsNotice": cfg.DiagnosticsNotice,
|
||||||
|
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt(r *http.Request, key string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAdminTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttendanceCheckinPayloadReturnsJSONObjectFields(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 18, 1, 27, 49, 0, time.UTC)
|
||||||
|
payload := attendanceCheckinPayload(store.AttendanceCheckin{
|
||||||
|
CheckinID: "attc_1",
|
||||||
|
Action: "check_in",
|
||||||
|
ClientTime: now,
|
||||||
|
ServerTime: now,
|
||||||
|
Result: "normal",
|
||||||
|
Location: `{"name":"当前位置","latitude":22.929670335639365}`,
|
||||||
|
Wifi: `{}`,
|
||||||
|
Device: `{"platform":"ios","device_id":"C7A76C6A-FC57-4CED-B1C1-CE5199944F8C"}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
location, ok := payload["location"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("location=%T, want map[string]any", payload["location"])
|
||||||
|
}
|
||||||
|
if location["name"] != "当前位置" {
|
||||||
|
t.Fatalf("location=%v", location)
|
||||||
|
}
|
||||||
|
if _, ok := payload["wifi"].(map[string]any); !ok {
|
||||||
|
t.Fatalf("wifi=%T, want map[string]any", payload["wifi"])
|
||||||
|
}
|
||||||
|
device, ok := payload["device"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("device=%T, want map[string]any", payload["device"])
|
||||||
|
}
|
||||||
|
if device["platform"] != "ios" {
|
||||||
|
t.Fatalf("device=%v", device)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const internalSignatureWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.Body.Close()
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
if err := a.verifyInternalAdminSignature(r, body); err != nil {
|
||||||
|
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error {
|
||||||
|
secret := strings.TrimSpace(a.authCfg.InternalSecret)
|
||||||
|
if secret == "" {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name"))
|
||||||
|
if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp"))
|
||||||
|
nonce := strings.TrimSpace(r.Header.Get("X-Nonce"))
|
||||||
|
signature := strings.TrimSpace(r.Header.Get("X-Signature"))
|
||||||
|
if timestampRaw == "" || nonce == "" || signature == "" {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
ts, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
when := time.Unix(ts, 0)
|
||||||
|
if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
signingText := strings.Join([]string{
|
||||||
|
r.Method,
|
||||||
|
r.URL.RequestURI(),
|
||||||
|
hex.EncodeToString(bodyHash[:]),
|
||||||
|
timestampRaw,
|
||||||
|
nonce,
|
||||||
|
}, "\n")
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(signingText))
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) {
|
||||||
|
t.Fatal("validateMailboxSubmission returned true for too-long content")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body apiError
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if body.Reason != reasonValidationFailed {
|
||||||
|
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Details["field"] != "content" {
|
||||||
|
t.Fatalf("details=%v", body.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeStoreError(rec, req, store.ErrPolicy)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body apiError
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if body.Reason != reasonValidationFailed {
|
||||||
|
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func workbenchModulePayloads(modules []store.WorkbenchModule) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(modules))
|
||||||
|
for _, module := range modules {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"code": module.Code,
|
||||||
|
"name": module.Name,
|
||||||
|
"icon_file_id": module.IconFileID,
|
||||||
|
"route": module.Route,
|
||||||
|
"sort_order": module.SortOrder,
|
||||||
|
"unread_count": module.UnreadCount,
|
||||||
|
"enabled": module.Enabled,
|
||||||
|
"permission": firstNonEmpty(module.Permission, "enabled"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizationDepartmentPayloads(departments []store.OrganizationDepartment) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(departments))
|
||||||
|
for _, department := range departments {
|
||||||
|
items = append(items, organizationDepartmentPayload(department))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizationDepartmentPayload(department store.OrganizationDepartment) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"department_id": department.DepartmentID,
|
||||||
|
"name": department.Name,
|
||||||
|
"parent_id": department.ParentID,
|
||||||
|
"child_count": department.ChildCount,
|
||||||
|
"member_count": department.MemberCount,
|
||||||
|
"version": department.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizationMemberPayloads(members []store.OrganizationMember) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(members))
|
||||||
|
for _, member := range members {
|
||||||
|
items = append(items, organizationMemberPayload(member))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizationMemberPayload(member store.OrganizationMember) map[string]any {
|
||||||
|
payload := map[string]any{
|
||||||
|
"user_id": formatUserID(member.UserID),
|
||||||
|
"department_id": member.DepartmentID,
|
||||||
|
"display_name": member.DisplayName,
|
||||||
|
"position": member.Position,
|
||||||
|
"avatar_file_id": member.AvatarFileID,
|
||||||
|
"status": member.Status,
|
||||||
|
"contactable": member.Contactable,
|
||||||
|
"version": member.Version,
|
||||||
|
}
|
||||||
|
if member.Mobile != "" {
|
||||||
|
payload["mobile"] = member.Mobile
|
||||||
|
}
|
||||||
|
if member.Email != "" {
|
||||||
|
payload["email"] = member.Email
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceConfigPayload(config *store.AttendanceConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"date": config.Date.Format("2006-01-02"),
|
||||||
|
"timezone": config.Timezone,
|
||||||
|
"rule": attendanceRulePayload(config.Rule),
|
||||||
|
"shifts": attendanceShiftPayloads(config.Shifts),
|
||||||
|
"allowed_actions": []string{"check_in", "check_out"},
|
||||||
|
"today_record": attendanceRecordPayload(config.TodayRecord),
|
||||||
|
"permissions": map[string]any{
|
||||||
|
"can_checkin": true,
|
||||||
|
"can_view_managed": true,
|
||||||
|
"can_appeal": config.Rule.AppealAllowed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceRulePayload(rule store.AttendanceRule) map[string]any {
|
||||||
|
data := map[string]any{
|
||||||
|
"rule_id": rule.RuleID,
|
||||||
|
"name": rule.Name,
|
||||||
|
"workday": rule.Workday,
|
||||||
|
"location_required": rule.LocationRequired,
|
||||||
|
"wifi_required": rule.WifiRequired,
|
||||||
|
"allow_outside_checkin": rule.AllowOutsideCheckin,
|
||||||
|
"appeal_allowed": rule.AppealAllowed,
|
||||||
|
}
|
||||||
|
if rule.AppealDeadline != nil {
|
||||||
|
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceShiftPayloads(shifts []store.AttendanceShift) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(shifts))
|
||||||
|
for _, shift := range shifts {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"shift_id": shift.ShiftID,
|
||||||
|
"name": shift.Name,
|
||||||
|
"work_start": shift.WorkStart,
|
||||||
|
"work_end": shift.WorkEnd,
|
||||||
|
"checkin_start": shift.CheckinStart,
|
||||||
|
"checkout_end": shift.CheckoutEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceRecordPayloads(records []store.AttendanceRecord) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(records))
|
||||||
|
for i := range records {
|
||||||
|
items = append(items, attendanceRecordPayload(&records[i]))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any {
|
||||||
|
if record == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"record_id": record.RecordID,
|
||||||
|
"user_id": formatUserID(record.UserID),
|
||||||
|
"date": record.Date.Format("2006-01-02"),
|
||||||
|
"rule_id": record.RuleID,
|
||||||
|
"shift_id": record.ShiftID,
|
||||||
|
"status": record.Status,
|
||||||
|
"checkins": attendanceCheckinPayloads(record.Checkins),
|
||||||
|
"exceptions": jsonList(record.Exceptions),
|
||||||
|
"appeal_status": record.AppealStatus,
|
||||||
|
"version": record.Version,
|
||||||
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceCheckinPayloads(checkins []store.AttendanceCheckin) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(checkins))
|
||||||
|
for _, checkin := range checkins {
|
||||||
|
items = append(items, attendanceCheckinPayload(checkin))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceCheckinPayload(checkin store.AttendanceCheckin) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"checkin_id": checkin.CheckinID,
|
||||||
|
"action": checkin.Action,
|
||||||
|
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
|
||||||
|
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
|
||||||
|
"result": checkin.Result,
|
||||||
|
"location": jsonObject(checkin.Location),
|
||||||
|
"wifi": jsonObject(checkin.Wifi),
|
||||||
|
"device": jsonObject(checkin.Device),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceStatsPayload(stats *store.AttendanceStats) map[string]any {
|
||||||
|
if stats == nil {
|
||||||
|
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"expected_count": stats.ExpectedCount,
|
||||||
|
"checked_count": stats.CheckedCount,
|
||||||
|
"exception_count": stats.ExceptionCount,
|
||||||
|
"not_checked_count": stats.NotCheckedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceAppealPayloads(appeals []store.AttendanceAppeal) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(appeals))
|
||||||
|
for i := range appeals {
|
||||||
|
items = append(items, attendanceAppealPayload(&appeals[i]))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any {
|
||||||
|
if appeal == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data := map[string]any{
|
||||||
|
"appeal_id": appeal.AppealID,
|
||||||
|
"record_id": appeal.RecordID,
|
||||||
|
"user_id": formatUserID(appeal.UserID),
|
||||||
|
"appeal_type": appeal.AppealType,
|
||||||
|
"reason": appeal.Reason,
|
||||||
|
"target_action": appeal.TargetAction,
|
||||||
|
"attachment_file_ids": jsonList(appeal.AttachmentFileIDs),
|
||||||
|
"status": appeal.Status,
|
||||||
|
"version": appeal.Version,
|
||||||
|
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if appeal.TargetTime != nil {
|
||||||
|
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(meetings))
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
items = append(items, meetingPayload(meeting, viewer))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
|
||||||
|
participants := meetingParticipantPayloads(meeting.Participants)
|
||||||
|
recorderID := meeting.RecorderID
|
||||||
|
if recorderID == 0 {
|
||||||
|
recorderID = meeting.OrganizerID
|
||||||
|
}
|
||||||
|
organizer := userSummary(meeting.OrganizerID, meeting.Organizer)
|
||||||
|
recorder := userSummary(recorderID, meeting.Recorder)
|
||||||
|
state := meetingState(meeting)
|
||||||
|
data := map[string]any{
|
||||||
|
"meeting_id": meeting.MeetingID,
|
||||||
|
"title": meeting.Title,
|
||||||
|
"organizer_id": formatUserID(meeting.OrganizerID),
|
||||||
|
"organizer": organizer,
|
||||||
|
"organizer_display_name": organizer["display_name"],
|
||||||
|
"recorder_user_id": formatUserID(recorderID),
|
||||||
|
"recorder": recorder,
|
||||||
|
"recorder_display_name": recorder["display_name"],
|
||||||
|
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
|
||||||
|
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
|
||||||
|
"location": meeting.Location,
|
||||||
|
"state": state,
|
||||||
|
"reminder_minutes": jsonList(meeting.ReminderMinutes),
|
||||||
|
"version": meeting.Version,
|
||||||
|
"participants": participants,
|
||||||
|
"participant_count": len(participants),
|
||||||
|
"participant_names": meetingParticipantNames(participants),
|
||||||
|
"permissions": meetingPermissions(meeting, viewer),
|
||||||
|
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if meeting.CanceledAt != nil {
|
||||||
|
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
for _, participant := range meeting.Participants {
|
||||||
|
if participant.UserID == viewer {
|
||||||
|
data["my_response"] = participant.Response
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(participants))
|
||||||
|
for _, participant := range participants {
|
||||||
|
summary := userSummary(participant.UserID, participant.User)
|
||||||
|
item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]}
|
||||||
|
if avatarFileID, ok := summary["avatar_file_id"]; ok {
|
||||||
|
item["avatar_file_id"] = avatarFileID
|
||||||
|
}
|
||||||
|
if participant.ResponseAt != nil {
|
||||||
|
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNoteListPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(meetings))
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
item := meetingPayload(meeting, viewer)
|
||||||
|
item["note_status"] = "empty"
|
||||||
|
item["file_count"] = 0
|
||||||
|
item["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteFile, viewer int64) map[string]any {
|
||||||
|
meetingData := meetingPayload(meeting, viewer)
|
||||||
|
fileItems := make([]map[string]any, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
fileItems = append(fileItems, meetingNoteFilePayload(file, viewer))
|
||||||
|
}
|
||||||
|
data := map[string]any{}
|
||||||
|
for k, v := range meetingData {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["meeting"] = meetingData
|
||||||
|
data["note_status"] = "empty"
|
||||||
|
if len(files) > 0 {
|
||||||
|
data["note_status"] = "available"
|
||||||
|
}
|
||||||
|
data["file_count"] = len(files)
|
||||||
|
data["files"] = fileItems
|
||||||
|
data["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
|
||||||
|
extension := meetingNoteFileExtension(file.FileName, file.MIMEType)
|
||||||
|
return map[string]any{
|
||||||
|
"note_file_id": file.NoteFileID,
|
||||||
|
"meeting_id": file.MeetingID,
|
||||||
|
"file_id": file.FileID,
|
||||||
|
"name": file.FileName,
|
||||||
|
"file_name": file.FileName,
|
||||||
|
"mime": file.MIMEType,
|
||||||
|
"mime_type": file.MIMEType,
|
||||||
|
"size": file.FileSize,
|
||||||
|
"file_size": file.FileSize,
|
||||||
|
"file_extension": extension,
|
||||||
|
"file_etag": file.FileETag,
|
||||||
|
"status": file.Status,
|
||||||
|
"preview_status": file.PreviewStatus,
|
||||||
|
"preview_mode": file.PreviewMode,
|
||||||
|
"preview_file_id": file.PreviewFileID,
|
||||||
|
"uploader_user_id": formatUserID(file.UploaderID),
|
||||||
|
"uploader": userSummary(file.UploaderID, file.Uploader),
|
||||||
|
"version": file.Version,
|
||||||
|
"permissions": map[string]any{
|
||||||
|
"can_preview": file.Status == "active",
|
||||||
|
"can_download": file.Status == "active",
|
||||||
|
"can_replace": file.Status == "active" && file.UploaderID == viewer,
|
||||||
|
"can_delete": file.Status == "active" && file.UploaderID == viewer,
|
||||||
|
},
|
||||||
|
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNoteFileExtension(fileName, mimeType string) string {
|
||||||
|
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(fileName)), ".")); ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType))
|
||||||
|
if len(exts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimPrefix(exts[0], "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyReportPayloads(reports []store.DailyReport) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(reports))
|
||||||
|
for _, report := range reports {
|
||||||
|
items = append(items, dailyReportPayload(report))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyReportPayload(report store.DailyReport) map[string]any {
|
||||||
|
data := map[string]any{
|
||||||
|
"report_id": report.ReportID,
|
||||||
|
"author_id": formatUserID(report.AuthorID),
|
||||||
|
"report_date": report.ReportDate.Format("2006-01-02"),
|
||||||
|
"content": report.Content,
|
||||||
|
"content_short": preview(report.Content),
|
||||||
|
"attachments": jsonList(report.Attachments),
|
||||||
|
"status": report.Status,
|
||||||
|
"read_state": report.ReadState,
|
||||||
|
"version": report.Version,
|
||||||
|
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if report.SubmittedAt != nil {
|
||||||
|
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(reports))
|
||||||
|
for _, report := range reports {
|
||||||
|
items = append(items, monthlyReportPayload(report))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func monthlyReportPayload(report store.MonthlyReport) map[string]any {
|
||||||
|
data := map[string]any{
|
||||||
|
"report_id": report.ReportID,
|
||||||
|
"author_id": formatUserID(report.AuthorID),
|
||||||
|
"month": report.Month,
|
||||||
|
"work_summary": report.WorkSummary,
|
||||||
|
"next_plan": report.NextPlan,
|
||||||
|
"attachments": jsonList(report.Attachments),
|
||||||
|
"status": report.Status,
|
||||||
|
"read_state": report.ReadState,
|
||||||
|
"version": report.Version,
|
||||||
|
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if report.SubmittedAt != nil {
|
||||||
|
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportStatsPayload(stats *store.ReportStats) map[string]any {
|
||||||
|
if stats == nil {
|
||||||
|
return map[string]any{
|
||||||
|
"expected_count": 0,
|
||||||
|
"submitted_count": 0,
|
||||||
|
"missing_count": 0,
|
||||||
|
"scope": map[string]any{"department_id": ""},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"expected_count": stats.ExpectedCount,
|
||||||
|
"submitted_count": stats.SubmittedCount,
|
||||||
|
"missing_count": stats.MissingCount,
|
||||||
|
"scope": map[string]any{"department_id": stats.DepartmentID},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailboxConfigPayload(config store.MailboxConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": config.Enabled,
|
||||||
|
"allow_anonymous": config.AllowAnonymous,
|
||||||
|
"required_fields": jsonList(config.RequiredFields),
|
||||||
|
"max_length": config.MaxLength,
|
||||||
|
"attachment_policy": jsonObject(config.AttachmentPolicy),
|
||||||
|
"notice": map[string]any{
|
||||||
|
"title": config.NoticeTitle,
|
||||||
|
"content": config.NoticeContent,
|
||||||
|
"version": config.Version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"submission_id": submission.SubmissionID,
|
||||||
|
"sender_id": formatUserID(submission.SenderID),
|
||||||
|
"subject": submission.Subject,
|
||||||
|
"anonymous": submission.Anonymous,
|
||||||
|
"attachment_file_ids": jsonList(submission.AttachmentFileIDs),
|
||||||
|
"notice_version": submission.NoticeVersion,
|
||||||
|
"status": submission.Status,
|
||||||
|
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||||
|
isOrganizer := meeting.OrganizerID == viewer
|
||||||
|
isScheduled := meetingState(meeting) == "scheduled"
|
||||||
|
return map[string]any{
|
||||||
|
"can_update": isOrganizer && isScheduled,
|
||||||
|
"can_cancel": isOrganizer && isScheduled,
|
||||||
|
"can_remind": isOrganizer && isScheduled,
|
||||||
|
"can_respond": !isOrganizer && isScheduled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||||
|
recorderID := meeting.RecorderID
|
||||||
|
if recorderID == 0 {
|
||||||
|
recorderID = meeting.OrganizerID
|
||||||
|
}
|
||||||
|
readable := meetingState(meeting) == "ended"
|
||||||
|
canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer)
|
||||||
|
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSummary(userID int64, summary store.UserSummary) map[string]any {
|
||||||
|
displayName := strings.TrimSpace(summary.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = formatUserID(userID)
|
||||||
|
}
|
||||||
|
data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName}
|
||||||
|
if strings.TrimSpace(summary.AvatarFileID) != "" {
|
||||||
|
data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingState(meeting store.Meeting) string {
|
||||||
|
state := strings.TrimSpace(meeting.State)
|
||||||
|
if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) {
|
||||||
|
return "ended"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingParticipantNames(participants []map[string]any) []string {
|
||||||
|
names := make([]string, 0, len(participants))
|
||||||
|
for _, participant := range participants {
|
||||||
|
name, _ := participant["display_name"].(string)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonList(raw string) []any {
|
||||||
|
var items []any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &items); err != nil {
|
||||||
|
return []any{}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonObject(raw string) map[string]any {
|
||||||
|
var item map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &item); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func preview(value string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(value))
|
||||||
|
if len(runes) <= 40 {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return string(runes[:40])
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeRange(startAt, endAt time.Time) string {
|
||||||
|
if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") {
|
||||||
|
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04")
|
||||||
|
}
|
||||||
|
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPayloadUserIDsAreStrings(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 16, 9, 30, 0, 0, time.UTC)
|
||||||
|
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payloads := []map[string]any{
|
||||||
|
organizationMemberPayload(store.OrganizationMember{UserID: userA}),
|
||||||
|
attendanceRecordPayload(&store.AttendanceRecord{RecordID: "att_1", UserID: userA, Date: now, CreatedAt: now, UpdatedAt: now}),
|
||||||
|
attendanceAppealPayload(&store.AttendanceAppeal{AppealID: "atta_1", UserID: userA, SubmittedAt: now, CreatedAt: now, UpdatedAt: now}),
|
||||||
|
meetingPayload(store.Meeting{
|
||||||
|
MeetingID: "mtg_1",
|
||||||
|
OrganizerID: userA,
|
||||||
|
RecorderID: userB,
|
||||||
|
StartAt: now,
|
||||||
|
EndAt: now.Add(time.Hour),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Participants: []store.MeetingParticipant{
|
||||||
|
{UserID: userA, Response: "accepted"},
|
||||||
|
{UserID: userB, Response: "pending"},
|
||||||
|
},
|
||||||
|
}, userA),
|
||||||
|
meetingNoteFilePayload(store.MeetingNoteFile{NoteFileID: "mnote_1", UploaderID: userA, CreatedAt: now, UpdatedAt: now}, userA),
|
||||||
|
dailyReportPayload(store.DailyReport{ReportID: "dr_1", AuthorID: userA, ReportDate: now, CreatedAt: now, UpdatedAt: now}),
|
||||||
|
monthlyReportPayload(store.MonthlyReport{ReportID: "mr_1", AuthorID: userA, CreatedAt: now, UpdatedAt: now}),
|
||||||
|
mailboxSubmissionPayload(store.MailboxSubmission{SubmissionID: "mb_1", SenderID: userA, SubmittedAt: now}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, payload := range payloads {
|
||||||
|
assertNoNumericUserIDs(t, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := meetingPayload(store.Meeting{
|
||||||
|
MeetingID: "mtg_1",
|
||||||
|
Title: "周会",
|
||||||
|
OrganizerID: userA,
|
||||||
|
RecorderID: userA,
|
||||||
|
Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||||
|
Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||||
|
StartAt: now.Add(-2 * time.Hour),
|
||||||
|
EndAt: now.Add(-time.Hour),
|
||||||
|
State: "scheduled",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Participants: []store.MeetingParticipant{
|
||||||
|
{UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}},
|
||||||
|
},
|
||||||
|
}, userA)
|
||||||
|
|
||||||
|
if payload["state"] != "ended" {
|
||||||
|
t.Fatalf("state=%v, want ended", payload["state"])
|
||||||
|
}
|
||||||
|
if payload["organizer_display_name"] != "张三" {
|
||||||
|
t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"])
|
||||||
|
}
|
||||||
|
names, ok := payload["participant_names"].([]string)
|
||||||
|
if !ok || len(names) != 1 || names[0] != "李四" {
|
||||||
|
t.Fatalf("participant_names=%#v", payload["participant_names"])
|
||||||
|
}
|
||||||
|
participants, ok := payload["participants"].([]map[string]any)
|
||||||
|
if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" {
|
||||||
|
t.Fatalf("participants=%#v", payload["participants"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingNoteFilePayloadKeepsOriginalNameAndExtension(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||||
|
NoteFileID: "mnote_1",
|
||||||
|
FileID: "file_1",
|
||||||
|
FileName: "会议纪要.pdf",
|
||||||
|
MIMEType: "application/pdf",
|
||||||
|
FileSize: 2048,
|
||||||
|
UploaderID: 1,
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, 1)
|
||||||
|
if payload["file_name"] != "会议纪要.pdf" || payload["name"] != "会议纪要.pdf" {
|
||||||
|
t.Fatalf("payload names = file_name:%v name:%v", payload["file_name"], payload["name"])
|
||||||
|
}
|
||||||
|
if payload["file_extension"] != "pdf" {
|
||||||
|
t.Fatalf("file_extension = %v, want pdf", payload["file_extension"])
|
||||||
|
}
|
||||||
|
if payload["mime"] != "application/pdf" || payload["size"] != int64(2048) {
|
||||||
|
t.Fatalf("payload aliases = mime:%v size:%v", payload["mime"], payload["size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingNoteFilePayloadUsesUploaderSummary(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||||
|
NoteFileID: "mnote_1",
|
||||||
|
UploaderID: 1573637795346386944,
|
||||||
|
Uploader: store.UserSummary{
|
||||||
|
UserID: 1573637795346386944,
|
||||||
|
DisplayName: "jcharles3",
|
||||||
|
},
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, 1573637795346386944)
|
||||||
|
uploader, ok := payload["uploader"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("uploader=%#v", payload["uploader"])
|
||||||
|
}
|
||||||
|
if uploader["display_name"] != "jcharles3" {
|
||||||
|
t.Fatalf("uploader.display_name=%v, want jcharles3", uploader["display_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoNumericUserIDs(t *testing.T, value any) {
|
||||||
|
t.Helper()
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, item := range typed {
|
||||||
|
if isUserIDKey(key) {
|
||||||
|
if _, ok := item.(string); !ok {
|
||||||
|
t.Fatalf("%s is %T, want string", key, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoNumericUserIDs(t, item)
|
||||||
|
}
|
||||||
|
case []map[string]any:
|
||||||
|
for _, item := range typed {
|
||||||
|
assertNoNumericUserIDs(t, item)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
assertNoNumericUserIDs(t, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUserIDKey(key string) bool {
|
||||||
|
return key == "user_id" ||
|
||||||
|
key == "organizer_id" ||
|
||||||
|
key == "recorder_user_id" ||
|
||||||
|
key == "uploader_user_id" ||
|
||||||
|
key == "author_id" ||
|
||||||
|
key == "sender_id" ||
|
||||||
|
strings.HasSuffix(key, "_user_id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
reasonMalformedRequest = "MALFORMED_REQUEST"
|
||||||
|
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
||||||
|
reasonAuthRequired = "AUTH_REQUIRED"
|
||||||
|
reasonForbidden = "FORBIDDEN"
|
||||||
|
reasonNotFound = "RESOURCE_NOT_FOUND"
|
||||||
|
reasonConflict = "CONFLICT"
|
||||||
|
reasonValidationFailed = "VALIDATION_FAILED"
|
||||||
|
reasonInternal = "INTERNAL"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
ServerTime string `json:"server_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
|
||||||
|
writeJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"data": data,
|
||||||
|
"request_id": requestID(r),
|
||||||
|
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
|
||||||
|
writeJSON(w, r, status, apiError{
|
||||||
|
Code: code,
|
||||||
|
Reason: reason,
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
RequestID: requestID(r),
|
||||||
|
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound), errors.Is(err, store.ErrNotFound):
|
||||||
|
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
||||||
|
case errors.Is(err, ErrForbidden), errors.Is(err, store.ErrForbidden), errors.Is(err, auth.ErrForbidden):
|
||||||
|
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||||
|
case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict):
|
||||||
|
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil)
|
||||||
|
case errors.Is(err, store.ErrPolicy):
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
|
||||||
|
default:
|
||||||
|
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("X-IM-Schema", firstNonEmpty(r.Header.Get("X-IM-Schema"), "1"))
|
||||||
|
if rid := requestID(r); rid != "" {
|
||||||
|
w.Header().Set("X-Request-ID", rid)
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestID(r *http.Request) string {
|
||||||
|
return r.Header.Get("X-Request-ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonNilInt64(values ...*int64) *int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/xtea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userID int64
|
||||||
|
|
||||||
|
const defaultUIDKey = "dkXxWLwqUNRfYmdXRzOkZw=="
|
||||||
|
|
||||||
|
var (
|
||||||
|
uidCodecMu sync.RWMutex
|
||||||
|
uidCipher *xtea.Cipher
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := SetUIDKey(defaultUIDKey); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetUIDKey(encoded string) error {
|
||||||
|
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid uid key: %w", err)
|
||||||
|
}
|
||||||
|
cipher, err := xtea.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid uid key: %w", err)
|
||||||
|
}
|
||||||
|
uidCodecMu.Lock()
|
||||||
|
uidCipher = cipher
|
||||||
|
uidCodecMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *userID) UnmarshalJSON(raw []byte) error {
|
||||||
|
value, err := parseUserIDJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*id = userID(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionalUserID struct {
|
||||||
|
value int64
|
||||||
|
set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *optionalUserID) UnmarshalJSON(raw []byte) error {
|
||||||
|
if strings.TrimSpace(string(raw)) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, err := parseUserIDJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id.set = true
|
||||||
|
id.value = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id optionalUserID) ptr() *int64 {
|
||||||
|
if !id.set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &id.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserIDJSON(raw []byte) (int64, error) {
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
return parseUserID(text)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("user id must be a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserID(value string) (int64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, fmt.Errorf("empty user id")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "usr") {
|
||||||
|
decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(strings.TrimPrefix(value, "usr"))
|
||||||
|
if err != nil || len(decoded) < 8 {
|
||||||
|
return 0, fmt.Errorf("invalid user id")
|
||||||
|
}
|
||||||
|
return decryptUID(decoded[:8]), nil
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(value, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUserID(id int64) string {
|
||||||
|
if id == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
encoded := encryptUID(id)
|
||||||
|
return "usr" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserIDList(values []userID) []int64 {
|
||||||
|
items := make([]int64, 0, len(values))
|
||||||
|
seen := make(map[int64]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
id := int64(value)
|
||||||
|
if id == 0 || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
items = append(items, id)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptUID(encoded []byte) int64 {
|
||||||
|
dst := make([]byte, 8)
|
||||||
|
uidCodecMu.RLock()
|
||||||
|
cipher := uidCipher
|
||||||
|
uidCodecMu.RUnlock()
|
||||||
|
cipher.Decrypt(dst, encoded[:8])
|
||||||
|
return int64(binary.LittleEndian.Uint64(dst))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptUID(id int64) []byte {
|
||||||
|
src := make([]byte, 8)
|
||||||
|
dst := make([]byte, 8)
|
||||||
|
binary.LittleEndian.PutUint64(src, uint64(id))
|
||||||
|
uidCodecMu.RLock()
|
||||||
|
cipher := uidCipher
|
||||||
|
uidCodecMu.RUnlock()
|
||||||
|
cipher.Encrypt(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserIDJSONAcceptsIMUserID(t *testing.T) {
|
||||||
|
var body struct {
|
||||||
|
RecorderUserID userID `json:"recorder_user_id"`
|
||||||
|
AttendeeUserIDs []userID `json:"attendee_user_ids"`
|
||||||
|
}
|
||||||
|
raw := []byte(`{"recorder_user_id":"usrag7w9xJ6hto","attendee_user_ids":["usr1RqTFgHSenM"]}`)
|
||||||
|
if err := json.Unmarshal(raw, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if body.RecorderUserID == 0 || len(body.AttendeeUserIDs) != 1 || body.AttendeeUserIDs[0] == 0 {
|
||||||
|
t.Fatalf("decoded zero ids: %+v", body)
|
||||||
|
}
|
||||||
|
if got := formatUserID(int64(body.RecorderUserID)); got != "usrag7w9xJ6hto" {
|
||||||
|
t.Fatalf("roundtrip recorder=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIDCodecMatchesIMServer(t *testing.T) {
|
||||||
|
const (
|
||||||
|
publicID = "usr3rZjh8TD_34"
|
||||||
|
dbID = int64(1574592702383656960)
|
||||||
|
)
|
||||||
|
got, err := parseUserID(publicID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUserID: %v", err)
|
||||||
|
}
|
||||||
|
if got != dbID {
|
||||||
|
t.Fatalf("parseUserID(%q)=%d, want %d", publicID, got, dbID)
|
||||||
|
}
|
||||||
|
if formatted := formatUserID(dbID); formatted != publicID {
|
||||||
|
t.Fatalf("formatUserID(%d)=%q, want %q", dbID, formatted, publicID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIDJSONRejectsNumericID(t *testing.T) {
|
||||||
|
var id userID
|
||||||
|
if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil {
|
||||||
|
t.Fatal("expected numeric user id to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package managerconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxResponseBody = 1 << 20
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg config.ManagerConfig
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveUIDKey(ctx context.Context, cfg config.ManagerConfig, fallback string) string {
|
||||||
|
key, err := New(cfg).UIDKey(ctx)
|
||||||
|
if err != nil || strings.TrimSpace(key) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ManagerConfig) *Client {
|
||||||
|
timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
http: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UIDKey(ctx context.Context) (string, error) {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
return "", fmt.Errorf("manager base url is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.cfg.Secret) == "" {
|
||||||
|
return "", fmt.Errorf("manager secret is empty")
|
||||||
|
}
|
||||||
|
keys := cleanList(c.cfg.UIDKeyKeys)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
keys = []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}
|
||||||
|
}
|
||||||
|
group := strings.TrimSpace(c.cfg.UIDKeyGroup)
|
||||||
|
if group == "" {
|
||||||
|
group = "im"
|
||||||
|
}
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("keys", strings.Join(keys, ","))
|
||||||
|
path := "/internal/im-config/third-party/" + url.PathEscape(group) + "?" + values.Encode()
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("manager status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
valuesMap, err := parseValues(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(valuesMap[key]); value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("uid key is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) newRequest(ctx context.Context, method string, rawURL string, body []byte) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||||
|
nonce, err := randomNonce()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
signingText := strings.Join([]string{
|
||||||
|
method,
|
||||||
|
req.URL.RequestURI(),
|
||||||
|
hex.EncodeToString(bodyHash[:]),
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
}, "\n")
|
||||||
|
mac := hmac.New(sha256.New, []byte(c.cfg.Secret))
|
||||||
|
_, _ = mac.Write([]byte(signingText))
|
||||||
|
serviceName := strings.TrimSpace(c.cfg.ServiceName)
|
||||||
|
if serviceName == "" {
|
||||||
|
serviceName = "im-workbench"
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Service-Name", serviceName)
|
||||||
|
req.Header.Set("X-Timestamp", timestamp)
|
||||||
|
req.Header.Set("X-Nonce", nonce)
|
||||||
|
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseValues(body []byte) (map[string]string, error) {
|
||||||
|
var envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 {
|
||||||
|
if envelope.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("manager code %d", envelope.Code)
|
||||||
|
}
|
||||||
|
body = envelope.Data
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Values == nil {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return payload.Values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanList(values []string) []string {
|
||||||
|
items := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
items = append(items, value)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomNonce() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package managerconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveUIDKeyPrefersManager(t *testing.T) {
|
||||||
|
const secret = "test-secret"
|
||||||
|
const managerKey = "manager-key"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/internal/im-config/third-party/im" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("keys") != "uid_encryption_key,UID_ENCRYPTION_KEY,uid_key" {
|
||||||
|
t.Fatalf("unexpected keys %s", r.URL.Query().Get("keys"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-Service-Name") != "im-workbench" {
|
||||||
|
t.Fatalf("unexpected service %s", r.Header.Get("X-Service-Name"))
|
||||||
|
}
|
||||||
|
if !validSignature(t, r, secret) {
|
||||||
|
t.Fatal("invalid signature")
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"values":{"uid_encryption_key":"manager-key"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
got := ResolveUIDKey(context.Background(), config.ManagerConfig{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
ServiceName: "im-workbench",
|
||||||
|
Secret: secret,
|
||||||
|
UIDKeyGroup: "im",
|
||||||
|
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
||||||
|
}, "fallback-key")
|
||||||
|
if got != managerKey {
|
||||||
|
t.Fatalf("ResolveUIDKey() = %q, want %q", got, managerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUIDKeyFallsBack(t *testing.T) {
|
||||||
|
got := ResolveUIDKey(context.Background(), config.ManagerConfig{}, "fallback-key")
|
||||||
|
if got != "fallback-key" {
|
||||||
|
t.Fatalf("ResolveUIDKey() = %q, want fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValuesAcceptsUnwrappedResponse(t *testing.T) {
|
||||||
|
values, err := parseValues([]byte(`{"values":{"UID_ENCRYPTION_KEY":"key"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if values["UID_ENCRYPTION_KEY"] != "key" {
|
||||||
|
t.Fatalf("unexpected values %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSignature(t *testing.T, r *http.Request, secret string) bool {
|
||||||
|
t.Helper()
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
signingText := strings.Join([]string{
|
||||||
|
r.Method,
|
||||||
|
r.URL.RequestURI(),
|
||||||
|
hex.EncodeToString(bodyHash[:]),
|
||||||
|
r.Header.Get("X-Timestamp"),
|
||||||
|
r.Header.Get("X-Nonce"),
|
||||||
|
}, "\n")
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(signingText))
|
||||||
|
return hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminWorkbenchModule struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Code string `db:"code"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
IconFileID string `db:"icon_file_id"`
|
||||||
|
Route string `db:"route"`
|
||||||
|
SortOrder int `db:"sort_order"`
|
||||||
|
UnreadKey string `db:"unread_key"`
|
||||||
|
Enabled int `db:"enabled"`
|
||||||
|
Permission string `db:"permission"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkbenchModuleInput struct {
|
||||||
|
ID int64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
IconFileID string
|
||||||
|
Route string
|
||||||
|
SortOrder int
|
||||||
|
UnreadKey string
|
||||||
|
Enabled int
|
||||||
|
Permission string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkbenchReorderItem struct {
|
||||||
|
Code string
|
||||||
|
SortOrder int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminMailboxConfig struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Enabled int `db:"enabled"`
|
||||||
|
AllowAnonymous int `db:"allow_anonymous"`
|
||||||
|
RequiredFields string `db:"required_fields"`
|
||||||
|
MaxLength int `db:"max_length"`
|
||||||
|
AttachmentPolicy string `db:"attachment_policy"`
|
||||||
|
NoticeTitle string `db:"notice_title"`
|
||||||
|
NoticeContent string `db:"notice_content"`
|
||||||
|
NoticeUpdatedAt time.Time `db:"notice_updated_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminFeedbackConfig struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Categories string `db:"categories"`
|
||||||
|
MaxLength int `db:"max_length"`
|
||||||
|
AttachmentPolicy string `db:"attachment_policy"`
|
||||||
|
AllowContact int `db:"allow_contact"`
|
||||||
|
DiagnosticsNotice string `db:"diagnostics_notice"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminWorkbenchModules(ctx context.Context, page, perPage int, code, name string, enabled *int) ([]AdminWorkbenchModule, int, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
if perPage > 200 {
|
||||||
|
perPage = 200
|
||||||
|
}
|
||||||
|
where := []string{"tenant_id=?"}
|
||||||
|
args := []any{s.tenant()}
|
||||||
|
if code = strings.TrimSpace(code); code != "" {
|
||||||
|
where = append(where, "code LIKE ?")
|
||||||
|
args = append(args, "%"+code+"%")
|
||||||
|
}
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
where = append(where, "name LIKE ?")
|
||||||
|
args = append(args, "%"+name+"%")
|
||||||
|
}
|
||||||
|
if enabled != nil {
|
||||||
|
where = append(where, "enabled=?")
|
||||||
|
args = append(args, *enabled)
|
||||||
|
}
|
||||||
|
whereSQL := strings.Join(where, " AND ")
|
||||||
|
var total int
|
||||||
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_workbench_apps WHERE "+whereSQL, args...); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
queryArgs := append([]any{}, args...)
|
||||||
|
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||||
|
var items []AdminWorkbenchModule
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at
|
||||||
|
FROM im_workbench_apps WHERE `+whereSQL+` ORDER BY sort_order ASC,id ASC LIMIT ?,?`, queryArgs...)
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertAdminWorkbenchModule(ctx context.Context, in AdminWorkbenchModuleInput) (*AdminWorkbenchModule, error) {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
in.Code = strings.TrimSpace(in.Code)
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
in.IconFileID = strings.TrimSpace(in.IconFileID)
|
||||||
|
in.Route = strings.TrimSpace(in.Route)
|
||||||
|
in.UnreadKey = strings.TrimSpace(in.UnreadKey)
|
||||||
|
in.Permission = strings.TrimSpace(in.Permission)
|
||||||
|
if in.Permission == "" {
|
||||||
|
in.Permission = "enabled"
|
||||||
|
}
|
||||||
|
if in.ID > 0 {
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET code=?,name=?,icon_file_id=?,route=?,sort_order=?,unread_key=?,enabled=?,permission=?,updated_at=? WHERE tenant_id=? AND id=?`,
|
||||||
|
in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, s.tenant(), in.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.adminWorkbenchModuleByID(ctx, in.ID)
|
||||||
|
}
|
||||||
|
res, err := s.db.ExecContext(ctx, `INSERT INTO im_workbench_apps(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.adminWorkbenchModuleByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DisableAdminWorkbenchModule(ctx context.Context, id int64, code string) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
var res sql.Result
|
||||||
|
var err error
|
||||||
|
if id > 0 {
|
||||||
|
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND id=?`, now, s.tenant(), id)
|
||||||
|
} else {
|
||||||
|
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND code=?`, now, s.tenant(), strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminWorkbenchReorderItem) error {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
for _, item := range items {
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE im_workbench_apps SET sort_order=?,updated_at=? WHERE tenant_id=? AND code=?`, item.SortOrder, now, s.tenant(), strings.TrimSpace(item.Code)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminOrganizationDepartments(ctx context.Context, page, perPage int, parentID, name, state string) ([]OrganizationDepartment, int, error) {
|
||||||
|
page, perPage = normalizeAdminPage(page, perPage)
|
||||||
|
where := []string{"tenant_id=?"}
|
||||||
|
args := []any{s.tenant()}
|
||||||
|
if parentID = strings.TrimSpace(parentID); parentID != "" {
|
||||||
|
where = append(where, "parent_id=?")
|
||||||
|
args = append(args, parentID)
|
||||||
|
}
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
where = append(where, "name LIKE ?")
|
||||||
|
args = append(args, "%"+name+"%")
|
||||||
|
}
|
||||||
|
if state = strings.TrimSpace(state); state != "" {
|
||||||
|
where = append(where, "state=?")
|
||||||
|
args = append(args, state)
|
||||||
|
}
|
||||||
|
whereSQL := strings.Join(where, " AND ")
|
||||||
|
var total int
|
||||||
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_departments WHERE "+whereSQL, args...); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
queryArgs := append([]any{}, args...)
|
||||||
|
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||||
|
var items []OrganizationDepartment
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT d.department_id,d.name,d.parent_id,
|
||||||
|
(SELECT COUNT(*) FROM im_org_departments c WHERE c.tenant_id=d.tenant_id AND c.parent_id=d.department_id AND c.state='active') AS child_count,
|
||||||
|
(SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count,
|
||||||
|
d.version
|
||||||
|
FROM im_org_departments d WHERE `+whereSQL+` ORDER BY d.parent_id ASC,d.version DESC,d.department_id ASC LIMIT ?,?`, queryArgs...)
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int, departmentID, keyword, state string, userID int64) ([]OrganizationMember, int, error) {
|
||||||
|
page, perPage = normalizeAdminPage(page, perPage)
|
||||||
|
where := []string{"tenant_id=?"}
|
||||||
|
args := []any{s.tenant()}
|
||||||
|
if departmentID = strings.TrimSpace(departmentID); departmentID != "" {
|
||||||
|
where = append(where, "department_id=?")
|
||||||
|
args = append(args, departmentID)
|
||||||
|
}
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ? OR email LIKE ?)")
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
if state = strings.TrimSpace(state); state != "" {
|
||||||
|
where = append(where, "employment_state=?")
|
||||||
|
args = append(args, state)
|
||||||
|
}
|
||||||
|
if userID > 0 {
|
||||||
|
where = append(where, "user_id=?")
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
whereSQL := strings.Join(where, " AND ")
|
||||||
|
var total int
|
||||||
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_members WHERE "+whereSQL, args...); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
queryArgs := append([]any{}, args...)
|
||||||
|
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||||
|
var items []OrganizationMember
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email,
|
||||||
|
IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version
|
||||||
|
FROM im_org_members WHERE `+whereSQL+` ORDER BY department_id ASC,user_id ASC LIMIT ?,?`, queryArgs...)
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) {
|
||||||
|
var cfg AdminMailboxConfig
|
||||||
|
if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
return &AdminMailboxConfig{Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", NoticeUpdatedAt: now, UpdatedAt: now}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`,
|
||||||
|
s.tenant(), cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) {
|
||||||
|
var cfg AdminFeedbackConfig
|
||||||
|
if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return &AdminFeedbackConfig{Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 2000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`,
|
||||||
|
s.tenant(), defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) adminWorkbenchModuleByID(ctx context.Context, id int64) (*AdminWorkbenchModule, error) {
|
||||||
|
var item AdminWorkbenchModule
|
||||||
|
if err := s.db.GetContext(ctx, &item, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at FROM im_workbench_apps WHERE tenant_id=? AND id=?`, s.tenant(), id); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIsNoRows(err error) bool {
|
||||||
|
return err == sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(value string, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdminPage(page, perPage int) (int, int) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
if perPage > 200 {
|
||||||
|
perPage = 200
|
||||||
|
}
|
||||||
|
return page, perPage
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AICapability struct {
|
||||||
|
Feature string `db:"feature"`
|
||||||
|
Enabled bool `db:"enabled"`
|
||||||
|
Model string `db:"model"`
|
||||||
|
DailyLimit int `db:"daily_limit"`
|
||||||
|
ConcurrencyLimit int `db:"concurrency_limit"`
|
||||||
|
MaxInputChars int `db:"max_input_chars"`
|
||||||
|
MaxFileSizeMB int `db:"max_file_size_mb"`
|
||||||
|
ConfigJSON string `db:"config_json"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AITask struct {
|
||||||
|
TaskID string `db:"task_id"`
|
||||||
|
TenantCode string `db:"tenant_code"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
Feature string `db:"feature"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
Progress int `db:"progress"`
|
||||||
|
SourceType string `db:"source_type"`
|
||||||
|
SourceRefID string `db:"source_ref_id"`
|
||||||
|
InputHash string `db:"input_hash"`
|
||||||
|
PromptVersion string `db:"prompt_version"`
|
||||||
|
Model string `db:"model"`
|
||||||
|
ResultRefID string `db:"result_ref_id"`
|
||||||
|
ResultJSON sql.NullString `db:"result_json"`
|
||||||
|
ErrorReason string `db:"error_reason"`
|
||||||
|
ErrorMessage string `db:"error_message"`
|
||||||
|
RetryCount int `db:"retry_count"`
|
||||||
|
StartedAt *time.Time `db:"started_at"`
|
||||||
|
FinishedAt *time.Time `db:"finished_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIRecord struct {
|
||||||
|
RecordID string `db:"record_id"`
|
||||||
|
TenantCode string `db:"tenant_code"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
RecordType string `db:"record_type"`
|
||||||
|
Title string `db:"title"`
|
||||||
|
Summary string `db:"summary"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
DetailID string `db:"detail_id"`
|
||||||
|
TaskID string `db:"task_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
DeletedAt *time.Time `db:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIDocument struct {
|
||||||
|
RecordID string `db:"record_id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
DocumentType string `db:"document_type"`
|
||||||
|
Title string `db:"title"`
|
||||||
|
ContentMarkdown string `db:"content_markdown"`
|
||||||
|
ContentPlain string `db:"content_plain"`
|
||||||
|
SectionsJSON string `db:"sections_json"`
|
||||||
|
SourceExcerpt string `db:"source_excerpt"`
|
||||||
|
PromptVersion string `db:"prompt_version"`
|
||||||
|
Model string `db:"model"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AITableAnalysis struct {
|
||||||
|
RecordID string `db:"record_id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
FileID string `db:"file_id"`
|
||||||
|
FileName string `db:"file_name"`
|
||||||
|
FileType string `db:"file_type"`
|
||||||
|
FileSize int64 `db:"file_size"`
|
||||||
|
Topic string `db:"topic"`
|
||||||
|
MetricsJSON string `db:"metrics_json"`
|
||||||
|
PreviewJSON string `db:"preview_json"`
|
||||||
|
AlertsJSON string `db:"alerts_json"`
|
||||||
|
Interpretation string `db:"interpretation"`
|
||||||
|
ExportFileID string `db:"export_file_id"`
|
||||||
|
PromptVersion string `db:"prompt_version"`
|
||||||
|
Model string `db:"model"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIMeetingSummary struct {
|
||||||
|
SummaryID string `db:"summary_id"`
|
||||||
|
MeetingID string `db:"meeting_id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
TaskID string `db:"task_id"`
|
||||||
|
ConclusionsJSON string `db:"conclusions_json"`
|
||||||
|
DecisionsJSON string `db:"decisions_json"`
|
||||||
|
ActionItemsJSON string `db:"action_items_json"`
|
||||||
|
SourceFileIDsJSON string `db:"source_file_ids_json"`
|
||||||
|
PromptVersion string `db:"prompt_version"`
|
||||||
|
Model string `db:"model"`
|
||||||
|
EditedBy string `db:"edited_by"`
|
||||||
|
EditedAt *time.Time `db:"edited_at"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIUsageLog struct {
|
||||||
|
TenantCode string
|
||||||
|
UserID string
|
||||||
|
Feature string
|
||||||
|
TaskID string
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
ProviderRequestID string
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
LatencyMS int
|
||||||
|
Status string
|
||||||
|
ErrorReason string
|
||||||
|
InputHash string
|
||||||
|
OutputHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIFileMetadata struct {
|
||||||
|
MIMEType string
|
||||||
|
FileSize int64
|
||||||
|
FileETag string
|
||||||
|
Location string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AICapabilities(ctx context.Context) ([]AICapability, error) {
|
||||||
|
var items []AICapability
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit,
|
||||||
|
max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json
|
||||||
|
FROM wb_ai_capability_config WHERE tenant_id=? ORDER BY feature ASC`, s.tenant())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
err = s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit,
|
||||||
|
max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json
|
||||||
|
FROM wb_ai_capability_config WHERE tenant_id=0 ORDER BY feature ASC`)
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AIFileMetadata(ctx context.Context, fileID string) AIFileMetadata {
|
||||||
|
meta := s.fileUploadMetadata(ctx, strings.TrimSpace(fileID))
|
||||||
|
return AIFileMetadata{
|
||||||
|
MIMEType: meta.MIMEType,
|
||||||
|
FileSize: meta.FileSize,
|
||||||
|
FileETag: meta.FileETag,
|
||||||
|
Location: meta.Location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAITask(ctx context.Context, task AITask, result any) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
resultJSON, err := marshalNullable(result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, `INSERT INTO wb_ai_task
|
||||||
|
(task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,input_hash,
|
||||||
|
input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count,
|
||||||
|
started_at,finished_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,NULL,?,?,?,?,?,?,0,?,?,?,?)`,
|
||||||
|
task.TaskID, s.tenant(), task.TenantCode, task.UserID, task.Feature, task.Status, task.Progress,
|
||||||
|
task.SourceType, task.SourceRefID, task.InputHash, task.PromptVersion, task.Model, task.ResultRefID,
|
||||||
|
resultJSON, task.ErrorReason, task.ErrorMessage, task.StartedAt, task.FinishedAt, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
resultJSON, err := marshalNullable(result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?,
|
||||||
|
error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=?`,
|
||||||
|
status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CancelAITask(ctx context.Context, userID int64, taskID string) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status='canceled',progress=100,finished_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND user_id=? AND task_id=? AND status IN ('queued','processing')`,
|
||||||
|
now, now, s.tenant(), formatInt64(userID), taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AITask(ctx context.Context, userID int64, taskID string) (*AITask, error) {
|
||||||
|
var task AITask
|
||||||
|
err := s.db.GetContext(ctx, &task, `SELECT task_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,
|
||||||
|
input_hash,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count,
|
||||||
|
started_at,finished_at,created_at,updated_at
|
||||||
|
FROM wb_ai_task WHERE tenant_id=? AND user_id=? AND task_id=?`, s.tenant(), formatInt64(userID), taskID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) InsertAIUsageLog(ctx context.Context, log AIUsageLog) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_usage_log
|
||||||
|
(tenant_id,tenant_code,user_id,feature,task_id,provider,model,provider_request_id,prompt_tokens,
|
||||||
|
completion_tokens,total_tokens,latency_ms,status,error_reason,input_hash,output_hash,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
s.tenant(), log.TenantCode, log.UserID, log.Feature, log.TaskID, log.Provider, log.Model, log.ProviderRequestID,
|
||||||
|
log.PromptTokens, log.CompletionTokens, log.TotalTokens, log.LatencyMS, log.Status, log.ErrorReason,
|
||||||
|
log.InputHash, log.OutputHash, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveAIDocument(ctx context.Context, tenantCode string, userID int64, record AIRecord, doc AIDocument) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record
|
||||||
|
(record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary,
|
||||||
|
record.Status, doc.RecordID, record.TaskID, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_document
|
||||||
|
(record_id,tenant_id,user_id,document_type,title,content_markdown,content_plain,sections_json,source_excerpt,
|
||||||
|
prompt_version,model,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
doc.RecordID, s.tenant(), formatInt64(userID), doc.DocumentType, doc.Title, doc.ContentMarkdown, doc.ContentPlain,
|
||||||
|
doc.SectionsJSON, doc.SourceExcerpt, doc.PromptVersion, doc.Model, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AIDocuments(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||||
|
return s.aiRecords(ctx, userID, "smart_document", queryText, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AIDocument(ctx context.Context, userID int64, recordID string) (*AIDocument, error) {
|
||||||
|
var doc AIDocument
|
||||||
|
err := s.db.GetContext(ctx, &doc, `SELECT d.record_id,d.user_id,d.document_type,d.title,d.content_markdown,
|
||||||
|
COALESCE(d.content_plain,'') AS content_plain,COALESCE(CAST(d.sections_json AS CHAR), '[]') AS sections_json,
|
||||||
|
d.source_excerpt,d.prompt_version,d.model,d.created_at,d.updated_at
|
||||||
|
FROM wb_ai_document d
|
||||||
|
JOIN wb_ai_record r ON r.tenant_id=d.tenant_id AND r.record_id=d.record_id
|
||||||
|
WHERE d.tenant_id=? AND d.user_id=? AND d.record_id=? AND r.deleted_at IS NULL`,
|
||||||
|
s.tenant(), formatInt64(userID), recordID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveAITableAnalysis(ctx context.Context, tenantCode string, userID int64, record AIRecord, analysis AITableAnalysis) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record
|
||||||
|
(record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary,
|
||||||
|
record.Status, analysis.RecordID, record.TaskID, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_table_analysis
|
||||||
|
(record_id,tenant_id,user_id,file_id,file_name,file_type,file_size,topic,metrics_json,preview_json,alerts_json,
|
||||||
|
interpretation,export_file_id,prompt_version,model,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
analysis.RecordID, s.tenant(), formatInt64(userID), analysis.FileID, analysis.FileName, analysis.FileType, analysis.FileSize,
|
||||||
|
analysis.Topic, analysis.MetricsJSON, analysis.PreviewJSON, analysis.AlertsJSON, analysis.Interpretation, analysis.ExportFileID,
|
||||||
|
analysis.PromptVersion, analysis.Model, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AITableAnalyses(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||||
|
return s.aiRecords(ctx, userID, "smart_table", queryText, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||||
|
return s.aiRecords(ctx, userID, recordType, queryText, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteAIRecord(ctx context.Context, userID int64, recordID string) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_record SET status='deleted',deleted_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND user_id=? AND record_id=? AND deleted_at IS NULL`,
|
||||||
|
now, now, s.tenant(), formatInt64(userID), recordID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AITableAnalysis(ctx context.Context, userID int64, recordID string) (*AITableAnalysis, error) {
|
||||||
|
var item AITableAnalysis
|
||||||
|
err := s.db.GetContext(ctx, &item, `SELECT a.record_id,a.user_id,a.file_id,a.file_name,a.file_type,a.file_size,a.topic,
|
||||||
|
COALESCE(CAST(a.metrics_json AS CHAR), '[]') AS metrics_json,
|
||||||
|
COALESCE(CAST(a.preview_json AS CHAR), '{}') AS preview_json,
|
||||||
|
COALESCE(CAST(a.alerts_json AS CHAR), '[]') AS alerts_json,
|
||||||
|
a.interpretation,a.export_file_id,a.prompt_version,a.model,a.created_at,a.updated_at
|
||||||
|
FROM wb_ai_table_analysis a
|
||||||
|
JOIN wb_ai_record r ON r.tenant_id=a.tenant_id AND r.record_id=a.record_id
|
||||||
|
WHERE a.tenant_id=? AND a.user_id=? AND a.record_id=? AND r.deleted_at IS NULL`,
|
||||||
|
s.tenant(), formatInt64(userID), recordID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertAIMeetingSummary(ctx context.Context, tenantCode string, userID int64, summary AIMeetingSummary) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_meeting_summary
|
||||||
|
(summary_id,tenant_id,tenant_code,meeting_id,user_id,task_id,conclusions_json,decisions_json,action_items_json,
|
||||||
|
source_file_ids_json,prompt_version,model,version,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE task_id=VALUES(task_id),conclusions_json=VALUES(conclusions_json),
|
||||||
|
decisions_json=VALUES(decisions_json),action_items_json=VALUES(action_items_json),
|
||||||
|
source_file_ids_json=VALUES(source_file_ids_json),prompt_version=VALUES(prompt_version),model=VALUES(model),
|
||||||
|
version=version+1,updated_at=VALUES(updated_at)`,
|
||||||
|
summary.SummaryID, s.tenant(), tenantCode, summary.MeetingID, formatInt64(userID), summary.TaskID,
|
||||||
|
summary.ConclusionsJSON, summary.DecisionsJSON, summary.ActionItemsJSON, summary.SourceFileIDsJSON,
|
||||||
|
summary.PromptVersion, summary.Model, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AIMeetingSummary(ctx context.Context, userID int64, meetingID string) (*AIMeetingSummary, error) {
|
||||||
|
var item AIMeetingSummary
|
||||||
|
err := s.db.GetContext(ctx, &item, `SELECT summary_id,meeting_id,user_id,task_id,
|
||||||
|
COALESCE(CAST(conclusions_json AS CHAR), '[]') AS conclusions_json,
|
||||||
|
COALESCE(CAST(decisions_json AS CHAR), '[]') AS decisions_json,
|
||||||
|
COALESCE(CAST(action_items_json AS CHAR), '[]') AS action_items_json,
|
||||||
|
COALESCE(CAST(source_file_ids_json AS CHAR), '[]') AS source_file_ids_json,
|
||||||
|
prompt_version,model,edited_by,edited_at,version,created_at,updated_at
|
||||||
|
FROM wb_ai_meeting_summary WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAIMeetingSummary(ctx context.Context, userID int64, meetingID, summaryID, conclusionsJSON, decisionsJSON, actionItemsJSON string) (*AIMeetingSummary, error) {
|
||||||
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if meeting.OrganizerID != userID && meeting.RecorderID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_meeting_summary SET conclusions_json=?,decisions_json=?,action_items_json=?,
|
||||||
|
edited_by=?,edited_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND summary_id=?`,
|
||||||
|
conclusionsJSON, decisionsJSON, actionItemsJSON, formatInt64(userID), now, now, s.tenant(), meetingID, summaryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.AIMeetingSummary(ctx, userID, meetingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) aiRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||||
|
where := `tenant_id=? AND user_id=? AND deleted_at IS NULL`
|
||||||
|
args := []any{s.tenant(), formatInt64(userID)}
|
||||||
|
if recordType != "" {
|
||||||
|
where += ` AND record_type=?`
|
||||||
|
args = append(args, recordType)
|
||||||
|
}
|
||||||
|
if queryText != "" {
|
||||||
|
where += ` AND (title LIKE ? OR summary LIKE ?)`
|
||||||
|
like := "%" + queryText + "%"
|
||||||
|
args = append(args, like, like)
|
||||||
|
}
|
||||||
|
if cursor != "" {
|
||||||
|
where += ` AND record_id<?`
|
||||||
|
args = append(args, cursor)
|
||||||
|
}
|
||||||
|
args = append(args, limit+1)
|
||||||
|
var items []AIRecord
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT record_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,
|
||||||
|
created_at,updated_at,deleted_at FROM wb_ai_record WHERE `+where+` ORDER BY created_at DESC,record_id DESC LIMIT ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(items) > limit {
|
||||||
|
next = items[limit-1].RecordID
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
return items, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalNullable(value any) (any, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt64(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
ErrPolicy = errors.New("policy violation")
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeMeetingNoteFileMetadataUsesUploadMetadata(t *testing.T) {
|
||||||
|
gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{
|
||||||
|
FileID: "lxzvtUzicEo",
|
||||||
|
}, fileUploadMetadata{
|
||||||
|
MIMEType: "application/pdf",
|
||||||
|
FileSize: 12345,
|
||||||
|
FileETag: "etag-1",
|
||||||
|
Location: "uploads/wontuse",
|
||||||
|
}, "lxzvtUzicEo")
|
||||||
|
if gotName != "lxzvtUzicEo.pdf" {
|
||||||
|
t.Fatalf("name=%q, want lxzvtUzicEo.pdf", gotName)
|
||||||
|
}
|
||||||
|
if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" {
|
||||||
|
t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMeetingNoteFileMetadataKeepsRequestMetadata(t *testing.T) {
|
||||||
|
size := int64(88)
|
||||||
|
gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{
|
||||||
|
FileID: "file_1",
|
||||||
|
FileName: "../会议纪要.docx",
|
||||||
|
MIMEType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
FileSize: &size,
|
||||||
|
FileETag: "request-etag",
|
||||||
|
}, fileUploadMetadata{
|
||||||
|
MIMEType: "application/pdf",
|
||||||
|
FileSize: 12345,
|
||||||
|
FileETag: "upload-etag",
|
||||||
|
Location: "uploads/file_1.pdf",
|
||||||
|
}, "file_1")
|
||||||
|
if gotName != "会议纪要.docx" {
|
||||||
|
t.Fatalf("name=%q, want original file name", gotName)
|
||||||
|
}
|
||||||
|
if gotMime != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || gotSize != 88 || gotETag != "request-etag" {
|
||||||
|
t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUploadFileIDAcceptsExtension(t *testing.T) {
|
||||||
|
decodedID, storageName := parseUploadFileID("lxzvtUzicEo.pdf")
|
||||||
|
if decodedID <= 0 {
|
||||||
|
t.Fatalf("decodedID=%d, want positive", decodedID)
|
||||||
|
}
|
||||||
|
if storageName == "" {
|
||||||
|
t.Fatal("storageName is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUploadFileIDAcceptsOSSURL(t *testing.T) {
|
||||||
|
decodedID, storageName := parseUploadFileID("https://oss.example.com/files/lxzvtUzicEo.pdf?Expires=1")
|
||||||
|
if decodedID <= 0 {
|
||||||
|
t.Fatalf("decodedID=%d, want positive", decodedID)
|
||||||
|
}
|
||||||
|
if storageName == "" {
|
||||||
|
t.Fatal("storageName is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUploadFileIDAcceptsObjectKey(t *testing.T) {
|
||||||
|
decodedID, storageName := parseUploadFileID("tenants/1/2026/09/s4oo7nkm4jyeu")
|
||||||
|
if decodedID != 0 {
|
||||||
|
t.Fatalf("decodedID=%d, want 0 for object key", decodedID)
|
||||||
|
}
|
||||||
|
if storageName != "s4oo7nkm4jyeu" {
|
||||||
|
t.Fatalf("storageName=%q, want s4oo7nkm4jyeu", storageName)
|
||||||
|
}
|
||||||
|
candidates := uploadLocationCandidates("tenants/1/2026/09/s4oo7nkm4jyeu", storageName)
|
||||||
|
if len(candidates) < 2 || candidates[0] != "s4oo7nkm4jyeu" || candidates[1] != "tenants/1/2026/09/s4oo7nkm4jyeu" {
|
||||||
|
t.Fatalf("candidates=%#v", candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantInitializationResult reports how many missing defaults were created.
|
||||||
|
// Existing tenant customizations are deliberately left untouched.
|
||||||
|
type TenantInitializationResult struct {
|
||||||
|
WorkbenchApps int64
|
||||||
|
AttendanceRules int64
|
||||||
|
AttendanceShifts int64
|
||||||
|
MailboxConfigs int64
|
||||||
|
FeedbackConfigs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeTenantDefaults creates the minimum workbench data required by a tenant.
|
||||||
|
// Tenant-scoped unique indexes make this operation safe to retry.
|
||||||
|
func (s *Store) InitializeTenantDefaults(ctx context.Context) (*TenantInitializationResult, error) {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
tenantID := s.tenant()
|
||||||
|
result := &TenantInitializationResult{}
|
||||||
|
|
||||||
|
apps := []struct {
|
||||||
|
code, name, route string
|
||||||
|
sort int
|
||||||
|
}{
|
||||||
|
{code: "attendance", name: "打卡", route: "/attendance", sort: 10},
|
||||||
|
{code: "monthly_reports", name: "月报", route: "/reports/monthly", sort: 20},
|
||||||
|
{code: "daily_reports", name: "日报", route: "/reports/daily", sort: 30},
|
||||||
|
{code: "organization", name: "部门框架", route: "/organization", sort: 40},
|
||||||
|
{code: "executive_mailbox", name: "举报信箱", route: "/executive-mailbox", sort: 50},
|
||||||
|
{code: "meetings", name: "会议通知", route: "/meetings", sort: 60},
|
||||||
|
{code: "meeting_notes", name: "会议笔记", route: "/meeting-notes", sort: 70},
|
||||||
|
}
|
||||||
|
for _, app := range apps {
|
||||||
|
res, execErr := tx.ExecContext(ctx, `INSERT INTO im_workbench_apps
|
||||||
|
(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||||
|
SELECT ?,?,?,?,?,?,?,?,?,?,?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM im_workbench_apps WHERE tenant_id=? AND code=?
|
||||||
|
)`,
|
||||||
|
tenantID, app.code, app.name, "", app.route, app.sort, "", 1, "enabled", now, now,
|
||||||
|
tenantID, app.code)
|
||||||
|
if execErr != nil {
|
||||||
|
return nil, execErr
|
||||||
|
}
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
result.WorkbenchApps += rows
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx, `INSERT INTO im_attendance_rules
|
||||||
|
(tenant_id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,effective_from,effective_to,state,created_at,updated_at)
|
||||||
|
SELECT ?,?,?,?,?,?,?,?,CURDATE(),NULL,'active',?,?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM im_attendance_rules WHERE tenant_id=? AND rule_id=?
|
||||||
|
)`,
|
||||||
|
tenantID, "att_rule_default", "默认考勤规则", "1,2,3,4,5", 0, 0, 1, 1, now, now,
|
||||||
|
tenantID, "att_rule_default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.AttendanceRules, _ = res.RowsAffected()
|
||||||
|
|
||||||
|
res, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_shifts
|
||||||
|
(tenant_id,shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,state,sort_order,created_at,updated_at)
|
||||||
|
SELECT ?,?,?,?,?,?,?,?,?,?,?,?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM im_attendance_shifts WHERE tenant_id=? AND shift_id=?
|
||||||
|
)`,
|
||||||
|
tenantID, "shift_default", "att_rule_default", "默认班次", "09:00:00", "18:00:00", "08:00:00", "19:00:00", "active", 10, now, now,
|
||||||
|
tenantID, "shift_default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.AttendanceShifts, _ = res.RowsAffected()
|
||||||
|
|
||||||
|
res, err = tx.ExecContext(ctx, `INSERT INTO im_mailbox_config
|
||||||
|
(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||||
|
SELECT ?,1,1,?,5000,?,'总经理信箱','请如实填写诉求。',?,?,?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM im_mailbox_config WHERE tenant_id=?
|
||||||
|
)`,
|
||||||
|
tenantID, `["subject","content"]`, `{"max_files":5,"purpose":"executive_mailbox"}`, now, now, now,
|
||||||
|
tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.MailboxConfigs, _ = res.RowsAffected()
|
||||||
|
|
||||||
|
res, err = tx.ExecContext(ctx, `INSERT INTO im_feedback_config
|
||||||
|
(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||||
|
SELECT ?,?,2000,?,1,'',?,?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM im_feedback_config WHERE tenant_id=?
|
||||||
|
)`,
|
||||||
|
tenantID, `["bug","suggestion","complaint","other"]`, `{"max_files":5,"purpose":"feedback"}`, now, now,
|
||||||
|
tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.FeedbackConfigs, _ = res.RowsAffected()
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type WorkbenchModule struct {
|
||||||
|
Code string `db:"code"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
IconFileID string `db:"icon_file_id"`
|
||||||
|
Route string `db:"route"`
|
||||||
|
SortOrder int `db:"sort_order"`
|
||||||
|
UnreadCount int `db:"unread_count"`
|
||||||
|
Enabled bool `db:"enabled"`
|
||||||
|
Permission string `db:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizationDepartment struct {
|
||||||
|
DepartmentID string `db:"department_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
ParentID string `db:"parent_id"`
|
||||||
|
ChildCount int `db:"child_count"`
|
||||||
|
MemberCount int `db:"member_count"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizationMember struct {
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
DepartmentID string `db:"department_id"`
|
||||||
|
DisplayName string `db:"display_name"`
|
||||||
|
Position string `db:"position"`
|
||||||
|
Mobile string `db:"mobile"`
|
||||||
|
Email string `db:"email"`
|
||||||
|
AvatarFileID string `db:"avatar_file_id"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
Contactable bool `db:"contactable"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSummary struct {
|
||||||
|
UserID int64
|
||||||
|
DisplayName string
|
||||||
|
AvatarFileID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceRule struct {
|
||||||
|
RuleID string `db:"rule_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Workday string `db:"workday"`
|
||||||
|
LocationRequired bool `db:"location_required"`
|
||||||
|
WifiRequired bool `db:"wifi_required"`
|
||||||
|
AllowOutsideCheckin bool `db:"allow_outside_checkin"`
|
||||||
|
AppealAllowed bool `db:"appeal_allowed"`
|
||||||
|
AppealDeadline *time.Time `db:"appeal_deadline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceShift struct {
|
||||||
|
ShiftID string `db:"shift_id"`
|
||||||
|
RuleID string `db:"rule_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
WorkStart string `db:"work_start"`
|
||||||
|
WorkEnd string `db:"work_end"`
|
||||||
|
CheckinStart string `db:"checkin_start"`
|
||||||
|
CheckoutEnd string `db:"checkout_end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceCheckin struct {
|
||||||
|
CheckinID string `db:"checkin_id"`
|
||||||
|
Action string `db:"action"`
|
||||||
|
ClientTime time.Time `db:"client_time"`
|
||||||
|
ServerTime time.Time `db:"server_time"`
|
||||||
|
Result string `db:"result"`
|
||||||
|
Location string `db:"location"`
|
||||||
|
Wifi string `db:"wifi"`
|
||||||
|
Device string `db:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceRecord struct {
|
||||||
|
RecordID string `db:"record_id"`
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
Date time.Time `db:"record_date"`
|
||||||
|
RuleID string `db:"rule_id"`
|
||||||
|
ShiftID string `db:"shift_id"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
Exceptions string `db:"exceptions"`
|
||||||
|
AppealStatus string `db:"appeal_status"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
Checkins []AttendanceCheckin
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceConfig struct {
|
||||||
|
Date time.Time
|
||||||
|
Timezone string
|
||||||
|
Rule AttendanceRule
|
||||||
|
Shifts []AttendanceShift
|
||||||
|
TodayRecord *AttendanceRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceStats struct {
|
||||||
|
ExpectedCount int
|
||||||
|
CheckedCount int
|
||||||
|
ExceptionCount int
|
||||||
|
NotCheckedCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttendanceAppeal struct {
|
||||||
|
AppealID string `db:"appeal_id"`
|
||||||
|
RecordID string `db:"record_id"`
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
AppealType string `db:"appeal_type"`
|
||||||
|
Reason string `db:"reason"`
|
||||||
|
TargetAction string `db:"target_action"`
|
||||||
|
TargetTime *time.Time `db:"target_time"`
|
||||||
|
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
SubmittedAt time.Time `db:"submitted_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meeting struct {
|
||||||
|
MeetingID string `db:"meeting_id"`
|
||||||
|
OrganizerID int64 `db:"organizer_id"`
|
||||||
|
RecorderID int64 `db:"recorder_user_id"`
|
||||||
|
Title string `db:"title"`
|
||||||
|
StartAt time.Time `db:"start_at"`
|
||||||
|
EndAt time.Time `db:"end_at"`
|
||||||
|
Location string `db:"location"`
|
||||||
|
State string `db:"state"`
|
||||||
|
ReminderMinutes string `db:"reminder_minutes"`
|
||||||
|
IdempotencyKey string `db:"idempotency_key"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
CanceledAt *time.Time `db:"canceled_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
Organizer UserSummary
|
||||||
|
Recorder UserSummary
|
||||||
|
Participants []MeetingParticipant
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingParticipant struct {
|
||||||
|
MeetingID string `db:"meeting_id"`
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
Response string `db:"response"`
|
||||||
|
ResponseAt *time.Time `db:"response_at"`
|
||||||
|
User UserSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingNoteFile struct {
|
||||||
|
NoteFileID string `db:"note_file_id"`
|
||||||
|
MeetingID string `db:"meeting_id"`
|
||||||
|
FileID string `db:"file_id"`
|
||||||
|
UploaderID int64 `db:"uploader_id"`
|
||||||
|
Uploader UserSummary
|
||||||
|
FileName string `db:"file_name"`
|
||||||
|
MIMEType string `db:"mime_type"`
|
||||||
|
FileSize int64 `db:"file_size"`
|
||||||
|
FileETag string `db:"file_etag"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
PreviewStatus string `db:"preview_status"`
|
||||||
|
PreviewMode string `db:"preview_mode"`
|
||||||
|
PreviewFileID string `db:"preview_file_id"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingNoteFileInput struct {
|
||||||
|
FileID string
|
||||||
|
FileName string
|
||||||
|
MIMEType string
|
||||||
|
FileSize *int64
|
||||||
|
FileETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyReport struct {
|
||||||
|
ReportID string `db:"report_id"`
|
||||||
|
AuthorID int64 `db:"author_id"`
|
||||||
|
ReportDate time.Time `db:"report_date"`
|
||||||
|
Content string `db:"content"`
|
||||||
|
Attachments string `db:"attachments"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
ReadState string `db:"read_state"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
SubmittedAt *time.Time `db:"submitted_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthlyReport struct {
|
||||||
|
ReportID string `db:"report_id"`
|
||||||
|
AuthorID int64 `db:"author_id"`
|
||||||
|
Month string `db:"report_month"`
|
||||||
|
WorkSummary string `db:"work_summary"`
|
||||||
|
NextPlan string `db:"next_plan"`
|
||||||
|
Attachments string `db:"attachments"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
ReadState string `db:"read_state"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
SubmittedAt *time.Time `db:"submitted_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportStats struct {
|
||||||
|
ExpectedCount int
|
||||||
|
SubmittedCount int
|
||||||
|
MissingCount int
|
||||||
|
DepartmentID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailboxConfig struct {
|
||||||
|
Enabled bool `db:"enabled"`
|
||||||
|
AllowAnonymous bool `db:"allow_anonymous"`
|
||||||
|
RequiredFields string `db:"required_fields"`
|
||||||
|
MaxLength int `db:"max_length"`
|
||||||
|
AttachmentPolicy string `db:"attachment_policy"`
|
||||||
|
NoticeTitle string `db:"notice_title"`
|
||||||
|
NoticeContent string `db:"notice_content"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailboxSubmission struct {
|
||||||
|
SubmissionID string `db:"submission_id"`
|
||||||
|
SenderID int64 `db:"sender_id"`
|
||||||
|
Subject string `db:"subject"`
|
||||||
|
Content string `db:"content"`
|
||||||
|
Anonymous bool `db:"anonymous"`
|
||||||
|
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||||
|
NoticeVersion string `db:"notice_version"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
SubmittedAt time.Time `db:"submitted_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package tableparse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSampleRows = 20
|
||||||
|
maxRows = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
Columns []ColumnStat `json:"columns"`
|
||||||
|
SampleRows [][]string `json:"sample_rows"`
|
||||||
|
RowCount int `json:"row_count"`
|
||||||
|
Stats map[string]NumericStat `json:"stats,omitempty"`
|
||||||
|
Outliers []Outlier `json:"outliers,omitempty"`
|
||||||
|
Parsed bool `json:"parsed"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Empty int `json:"empty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumericStat struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Sum float64 `json:"sum"`
|
||||||
|
Avg float64 `json:"avg"`
|
||||||
|
Min float64 `json:"min"`
|
||||||
|
Max float64 `json:"max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Outlier struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Column string `json:"column"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(path, fileType string) (Profile, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(fileType)) {
|
||||||
|
case "csv":
|
||||||
|
return parseCSV(path)
|
||||||
|
case "xlsx":
|
||||||
|
return parseXLSX(path)
|
||||||
|
case "xls":
|
||||||
|
return Profile{Parsed: false, Message: "legacy xls parsing is not supported yet"}, nil
|
||||||
|
default:
|
||||||
|
return Profile{}, fmt.Errorf("unsupported table file type: %s", fileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCSV(path string) (Profile, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
reader := csv.NewReader(f)
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
rows := make([][]string, 0, maxRows)
|
||||||
|
for len(rows) < maxRows {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
if emptyRow(row) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, trimRow(row))
|
||||||
|
}
|
||||||
|
return profileRows(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseXLSX(path string) (Profile, error) {
|
||||||
|
f, err := excelize.OpenFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
for _, sheet := range f.GetSheetList() {
|
||||||
|
rows, err := f.GetRows(sheet)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned := make([][]string, 0, min(len(rows), maxRows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if emptyRow(row) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, trimRow(row))
|
||||||
|
if len(cleaned) >= maxRows {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) > 0 {
|
||||||
|
return profileRows(cleaned), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Profile{Parsed: false, Message: "no non-empty sheet found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileRows(rows [][]string) Profile {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return Profile{Parsed: false, Message: "empty table"}
|
||||||
|
}
|
||||||
|
headers := rows[0]
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return Profile{Parsed: false, Message: "empty header"}
|
||||||
|
}
|
||||||
|
dataRows := rows[1:]
|
||||||
|
columnCount := len(headers)
|
||||||
|
columns := make([]ColumnStat, columnCount)
|
||||||
|
statsWork := make([]numericWork, columnCount)
|
||||||
|
for i, name := range headers {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("列%d", i+1)
|
||||||
|
}
|
||||||
|
columns[i] = ColumnStat{Name: name, Type: "text"}
|
||||||
|
}
|
||||||
|
sample := make([][]string, 0, min(len(dataRows), maxSampleRows))
|
||||||
|
for rowIndex, row := range dataRows {
|
||||||
|
normalized := normalizeWidth(row, columnCount)
|
||||||
|
if len(sample) < maxSampleRows {
|
||||||
|
sample = append(sample, normalized)
|
||||||
|
}
|
||||||
|
for col := 0; col < columnCount; col++ {
|
||||||
|
value := strings.TrimSpace(normalized[col])
|
||||||
|
if value == "" {
|
||||||
|
columns[col].Empty++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parsed, ok := parseNumber(value); ok {
|
||||||
|
statsWork[col].add(parsed, rowIndex+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats := map[string]NumericStat{}
|
||||||
|
outliers := []Outlier{}
|
||||||
|
for i := range columns {
|
||||||
|
work := statsWork[i]
|
||||||
|
nonEmpty := len(dataRows) - columns[i].Empty
|
||||||
|
if work.Count > 0 && work.Count*2 >= max(1, nonEmpty) {
|
||||||
|
columns[i].Type = "number"
|
||||||
|
stat := work.stat()
|
||||||
|
stats[columns[i].Name] = stat
|
||||||
|
if stat.Count >= 3 {
|
||||||
|
for _, point := range work.Points {
|
||||||
|
if point.Value == stat.Max && stat.Avg > 0 && point.Value > stat.Avg*2 {
|
||||||
|
outliers = append(outliers, Outlier{Row: point.Row, Column: columns[i].Name, Description: "数值明显高于平均值"})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Profile{Columns: columns, SampleRows: sample, RowCount: len(dataRows), Stats: stats, Outliers: outliers, Parsed: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
type numericWork struct {
|
||||||
|
Count int
|
||||||
|
Sum float64
|
||||||
|
Min float64
|
||||||
|
Max float64
|
||||||
|
Points []numericPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
type numericPoint struct {
|
||||||
|
Row int
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *numericWork) add(value float64, row int) {
|
||||||
|
if w.Count == 0 {
|
||||||
|
w.Min = value
|
||||||
|
w.Max = value
|
||||||
|
} else {
|
||||||
|
w.Min = math.Min(w.Min, value)
|
||||||
|
w.Max = math.Max(w.Max, value)
|
||||||
|
}
|
||||||
|
w.Count++
|
||||||
|
w.Sum += value
|
||||||
|
if len(w.Points) < 200 {
|
||||||
|
w.Points = append(w.Points, numericPoint{Row: row, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w numericWork) stat() NumericStat {
|
||||||
|
avg := 0.0
|
||||||
|
if w.Count > 0 {
|
||||||
|
avg = w.Sum / float64(w.Count)
|
||||||
|
}
|
||||||
|
return NumericStat{Count: w.Count, Sum: w.Sum, Avg: avg, Min: w.Min, Max: w.Max}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNumber(value string) (float64, bool) {
|
||||||
|
value = strings.TrimSpace(strings.ReplaceAll(value, ",", ""))
|
||||||
|
value = strings.TrimSuffix(value, "%")
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
return parsed, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWidth(row []string, width int) []string {
|
||||||
|
out := make([]string, width)
|
||||||
|
for i := 0; i < width && i < len(row); i++ {
|
||||||
|
out[i] = strings.TrimSpace(row[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimRow(row []string) []string {
|
||||||
|
out := make([]string, len(row))
|
||||||
|
for i := range row {
|
||||||
|
out[i] = strings.TrimSpace(row[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyRow(row []string) bool {
|
||||||
|
for _, value := range row {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package tableparse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCSVBuildsProfile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sales.csv")
|
||||||
|
if err := os.WriteFile(path, []byte("date,sales,orders\n8/1,100,2\n8/2,200,4\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := Parse(path, "csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !profile.Parsed || profile.RowCount != 2 {
|
||||||
|
t.Fatalf("profile parsed=%v rowCount=%d, want parsed=true rowCount=2", profile.Parsed, profile.RowCount)
|
||||||
|
}
|
||||||
|
if got := profile.Stats["sales"].Sum; got != 300 {
|
||||||
|
t.Fatalf("sales sum = %v, want 300", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseXLSXBuildsProfile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sales.xlsx")
|
||||||
|
f := excelize.NewFile()
|
||||||
|
if err := f.SetSheetRow("Sheet1", "A1", &[]any{"date", "sales"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.SetSheetRow("Sheet1", "A2", &[]any{"8/1", 120}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.SaveAs(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := Parse(path, "xlsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !profile.Parsed || profile.Columns[1].Type != "number" {
|
||||||
|
t.Fatalf("profile parsed=%v sales type=%q, want parsed=true number", profile.Parsed, profile.Columns[1].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user