Compare commits

..
1 Commits
Author SHA1 Message Date
dev 6e70c65e14 Initial commit 2026-10-18 12:17:00 +08:00
47 changed files with 2 additions and 14167 deletions
-37
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
-10
View File
@@ -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 -145
View File
@@ -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 审核后人工执行。
-58
View File
@@ -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)
}
}
-57
View File
@@ -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
}
}
}
-46
View File
@@ -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
-230
View File
@@ -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
}
}
```
-351
View File
@@ -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,先确认是否拉到最新提交。
-56
View File
@@ -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` | 提交总经理信箱 |
-40
View File
@@ -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
)
-120
View File
@@ -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=
-197
View File
@@ -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"`
}
-387
View File
@@ -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")
}
-88
View File
@@ -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()
}
}
-198
View File
@@ -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 ""
}
-316
View File
@@ -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
}
-41
View File
@@ -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)
}
}
-180
View File
@@ -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)
}
-392
View File
@@ -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)
}
}
-9
View File
@@ -1,9 +0,0 @@
package httpapi
import "errors"
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict")
)
-72
View File
@@ -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
}
-51
View File
@@ -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())
}
}
-547
View File
@@ -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")
}
-171
View File
@@ -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")
}
-98
View File
@@ -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
}
-142
View File
@@ -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
}
-47
View File
@@ -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")
}
}
-170
View File
@@ -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
}
-83
View File
@@ -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))))
}
-306
View File
@@ -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
}
-459
View File
@@ -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)
}
-10
View File
@@ -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")
)
-76
View File
@@ -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
-115
View File
@@ -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
}
-234
View File
@@ -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"`
}
-254
View File
@@ -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
}
-49
View File
@@ -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)
}
}