Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e70c65e14 |
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
+1
-2
@@ -3,7 +3,6 @@
|
|||||||
# 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
|
||||||
@@ -25,7 +24,6 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
.idea/
|
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
@@ -164,3 +162,4 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.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,146 +1,2 @@
|
|||||||
# im_workbench
|
# olons_server
|
||||||
|
|
||||||
工作台服务从 `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 审核后人工执行。
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,230 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
# 工作台与 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
# 工作台迁移对接总结文档
|
|
||||||
|
|
||||||
> 更新时间: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,先确认是否拉到最新提交。
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 工作台服务迁移说明
|
|
||||||
|
|
||||||
本仓库承接 `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` | 提交总经理信箱 |
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
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
@@ -1,40 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNotFound = errors.New("resource not found")
|
|
||||||
ErrForbidden = errors.New("forbidden")
|
|
||||||
ErrConflict = errors.New("conflict")
|
|
||||||
)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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))))
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNotFound = errors.New("resource not found")
|
|
||||||
ErrForbidden = errors.New("forbidden")
|
|
||||||
ErrConflict = errors.New("conflict")
|
|
||||||
ErrPolicy = errors.New("policy violation")
|
|
||||||
)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
@@ -1,115 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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