From 982e71f0933e03b4e7a7c301f24ab34ec8ca7e57 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 13 Sep 2026 22:17:01 +0800 Subject: [PATCH] feat: scaffold independent workbench service --- .gitignore | 3 +- Dockerfile | 15 + Makefile | 10 + README.md | 87 +++- cmd/workbench/main.go | 58 +++ configs/workbench.local.json | 27 + configs/workbench.test.json | 31 ++ docs/迁移说明.md | 56 ++ go.mod | 32 ++ go.sum | 104 ++++ internal/app/app.go | 78 +++ internal/auth/auth.go | 198 +++++++ internal/config/config.go | 178 +++++++ internal/events/events.go | 181 +++++++ internal/httpapi/api.go | 974 +++++++++++++++++++++++++++++++++++ internal/httpapi/errors.go | 9 + internal/httpapi/payload.go | 474 +++++++++++++++++ internal/httpapi/response.go | 86 ++++ internal/store/errors.go | 9 + internal/store/mysql.go | 826 +++++++++++++++++++++++++++++ internal/store/types.go | 215 ++++++++ 21 files changed, 3649 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/workbench/main.go create mode 100644 configs/workbench.local.json create mode 100644 configs/workbench.test.json create mode 100644 docs/迁移说明.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/events/events.go create mode 100644 internal/httpapi/api.go create mode 100644 internal/httpapi/errors.go create mode 100644 internal/httpapi/payload.go create mode 100644 internal/httpapi/response.go create mode 100644 internal/store/errors.go create mode 100644 internal/store/mysql.go create mode 100644 internal/store/types.go diff --git a/.gitignore b/.gitignore index fbd390a..205c5f3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins +bin/ *.exe *.exe~ *.dll @@ -24,6 +25,7 @@ go.work.sum # env file .env +.idea/ # ---> Node # Logs @@ -162,4 +164,3 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0479c25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +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 +COPY --from=builder /out/im_workbench /app/im_workbench +COPY configs /app/configs +EXPOSE 6061 +ENTRYPOINT ["/app/im_workbench"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eb90e8f --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build test run + +build: + go build -o bin/im_workbench ./cmd/workbench + +test: + go test ./... + +run: + IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench diff --git a/README.md b/README.md index c7f3f51..7760568 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,88 @@ # im_workbench -im工作台代码 \ No newline at end of file +工作台服务从 `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/*` + +## 本地运行 + +```bash +IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench +``` + +默认监听 `:6061`,默认连接本地 `tinode` 库和本地 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)}` 校验登录态。 + +## 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` 中的 SASL/SCRAM-SHA-512 配置。 + +事件体字段: + +```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 审核后人工执行。 diff --git a/cmd/workbench/main.go b/cmd/workbench/main.go new file mode 100644 index 0000000..bc91282 --- /dev/null +++ b/cmd/workbench/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.118im.com/im_team/im_workbench/internal/app" + "git.118im.com/im_team/im_workbench/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + server, err := app.New(cfg) + if err != nil { + log.Fatalf("init app: %v", err) + } + defer server.Close() + + httpServer := &http.Server{ + Addr: cfg.Listen, + Handler: server.Handler(), + ReadHeaderTimeout: 5 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + log.Printf("im_workbench listening on %s", cfg.Listen) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + stopCh := make(chan os.Signal, 1) + signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errCh: + log.Fatalf("serve: %v", err) + case <-stopCh: + log.Printf("shutdown requested") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } +} diff --git a/configs/workbench.local.json b/configs/workbench.local.json new file mode 100644 index 0000000..f58d4a6 --- /dev/null +++ b/configs/workbench.local.json @@ -0,0 +1,27 @@ +{ + "listen": ":6061", + "mysql": { + "dsn": "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local" + }, + "redis": { + "addr": "127.0.0.1:6379", + "db": 0, + "key_prefix": "im:" + }, + "kafka": { + "enabled": false, + "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", + "allow_dev_header_auth": true, + "required_tenant_code": true, + "required_api_key": false, + "skip_session_in_dev": false + } +} diff --git a/configs/workbench.test.json b/configs/workbench.test.json new file mode 100644 index 0000000..dc04ae2 --- /dev/null +++ b/configs/workbench.test.json @@ -0,0 +1,31 @@ +{ + "listen": ":6061", + "mysql": { + "dsn": "root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local" + }, + "redis": { + "addr": "64.118.137.182:56379", + "password": "HMTJzkm.h>Y+E9ep", + "db": 0, + "key_prefix": "im:" + }, + "kafka": { + "enabled": true, + "brokers": [ + "64.118.137.182:9094" + ], + "topic": "im.workbench.event", + "username": "kafka-external", + "password": "gr58tNnlZOVGW3AmMbL2", + "sasl": "SCRAM-SHA-512", + "client_id": "im_workbench_test" + }, + "auth": { + "api_keys": [], + "session_key_pattern": "im:session:token:%s", + "allow_dev_header_auth": false, + "required_tenant_code": true, + "required_api_key": true, + "skip_session_in_dev": false + } +} diff --git a/docs/迁移说明.md b/docs/迁移说明.md new file mode 100644 index 0000000..5910e78 --- /dev/null +++ b/docs/迁移说明.md @@ -0,0 +1,56 @@ +# 工作台服务迁移说明 + +本仓库承接 `im_server` 中的工作台接口,第一阶段目标是让工作台服务独立编译、独立启动、独立鉴权,并由网关把 `/v1/workbench/*` 分流到本服务。 + +## 已迁移接口 + +| 模块 | 新路径 | +|---|---| +| 工作台入口 | `GET /v1/workbench` | +| 组织架构 | `/v1/workbench/organization/*` | +| 考勤 | `/v1/workbench/attendance/*` | +| 会议 | `/v1/workbench/meetings*` | +| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | +| 日报 | `/v1/workbench/reports/daily*` | +| 月报 | `/v1/workbench/reports/monthly*` | +| 总经理信箱 | `/v1/workbench/executive-mailbox/*` | + +## 当前边界 + +1. 数据库暂不迁移,仍访问现有租户库工作台表。 +2. 文件上传仍由 `im_server` 的 `/v0/file/u/` 处理,工作台只保存 `file_id` 引用。 +3. C 端实时通知仍由 `im_server` 下发;工作台后续只投 Kafka 业务事件。 +4. `im_server` 原工作台代码暂不删除,待本服务联调稳定后再清理。 +5. 服务启动不自动创建或修改表结构。 + +## 待联调项 + +1. `im_server` 登录后写入 Redis session:`im:session:token:{sha256(token)}`。 +2. Nginx/API Gateway 路由:`/v1/workbench/* -> im_workbench:6061`。 +3. 工作台业务事件 Kafka 化:会议、考勤、日报、月报、信箱事件投递给 `im_server`。 +4. 数据库专项迁移:接口稳定后再从租户库拆到工作台库。 + +## Kafka 事件范围 + +当前工作台服务生产端统一投递到 `im.workbench.event`,由 `im_server` 后续接入消费组并转为系统通知、未读数或 WS 实时下发。 + +| 模块 | Action | 触发场景 | +|---|---|---| +| 考勤 | `attendance.record.updated` | 打卡成功 | +| 考勤 | `attendance.appeal.created` | 提交考勤申诉 | +| 会议 | `meeting.created` | 创建会议 | +| 会议 | `meeting.updated` | 修改会议 | +| 会议 | `meeting.canceled` | 取消会议 | +| 会议 | `meeting.response.updated` | 参会人更新回执 | +| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 | +| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 | +| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 | +| 日报 | `daily_report.draft.created` | 创建日报草稿 | +| 日报 | `daily_report.draft.saved` | 保存日报草稿 | +| 日报 | `daily_report.submitted` | 提交日报 | +| 日报 | `daily_report.reminded` | 日报提醒 | +| 月报 | `monthly_report.draft.created` | 创建月报草稿 | +| 月报 | `monthly_report.draft.saved` | 保存月报草稿 | +| 月报 | `monthly_report.submitted` | 提交月报 | +| 月报 | `monthly_report.reopened` | 重开月报 | +| 信箱 | `mailbox.submitted` | 提交总经理信箱 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aed48b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +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 +) + +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/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 + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf48577 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +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/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/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/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/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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..eef5c99 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "net/http" + "time" + + "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/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) { + 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 + } + return &App{ + store: st, + redis: redisClient, + events: publisher, + api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher), + }, 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() + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b1e3ec8 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,198 @@ +package auth + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" + "github.com/redis/go-redis/v9" +) + +var ( + ErrAuthRequired = errors.New("authentication required") + ErrForbidden = errors.New("forbidden") +) + +type Principal struct { + TenantID int64 + TenantCode string + UserID int64 + DeviceID string + AuthLevel string +} + +type Session struct { + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + DeviceID string `json:"device_id"` + AuthLevel string `json:"auth_level"` + State string `json:"state"` + ExpiresAt string `json:"expires_at"` + IssuedAt string `json:"issued_at"` +} + +type Authenticator struct { + cfg config.AuthConfig + redis *redis.Client +} + +func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator { + return &Authenticator{cfg: cfg, redis: redisClient} +} + +func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) { + if err := a.validateAPIKey(req); err != nil { + return nil, err + } + if a.cfg.AllowDevHeaderAuth { + if principal := principalFromDevHeaders(req); principal != nil { + return principal, nil + } + } + if a.cfg.SkipSessionInDev { + return &Principal{ + TenantID: parseInt64Header(req, "X-Tenant-ID", 1), + TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"), + UserID: parseInt64Header(req, "X-User-ID", 1), + AuthLevel: "auth", + }, nil + } + if a.redis == nil { + return nil, ErrAuthRequired + } + token, err := bearerToken(req) + if err != nil { + return nil, err + } + session, err := a.sessionFromRedis(req.Context(), token) + if err != nil { + return nil, err + } + if session.UserID <= 0 || session.TenantID <= 0 { + return nil, ErrAuthRequired + } + if state := strings.ToLower(strings.TrimSpace(session.State)); state != "" && state != "active" { + return nil, ErrAuthRequired + } + if session.ExpiresAt != "" { + expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt) + if err == nil && time.Now().After(expiresAt) { + return nil, ErrAuthRequired + } + } + headerTenant := strings.TrimSpace(req.Header.Get("X-Tenant-Code")) + if a.cfg.RequiredTenantCode && headerTenant == "" { + return nil, ErrForbidden + } + if headerTenant != "" && session.TenantCode != "" && headerTenant != session.TenantCode { + return nil, ErrForbidden + } + return &Principal{ + TenantID: session.TenantID, + TenantCode: firstNonEmpty(session.TenantCode, headerTenant), + UserID: session.UserID, + DeviceID: session.DeviceID, + AuthLevel: session.AuthLevel, + }, nil +} + +func (a *Authenticator) validateAPIKey(req *http.Request) error { + if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey { + return nil + } + got := strings.TrimSpace(req.Header.Get("X-MeetingIM-APIKey")) + if got == "" { + got = strings.TrimSpace(req.URL.Query().Get("apikey")) + } + if got == "" { + if a.cfg.RequiredAPIKey { + return ErrForbidden + } + return nil + } + if len(a.cfg.APIKeys) == 0 { + return nil + } + for _, key := range a.cfg.APIKeys { + if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 { + return nil + } + } + return ErrForbidden +} + +func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) { + secret := []byte(strings.TrimSpace(token)) + if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil { + secret = decoded + } + digest := sha256.Sum256(secret) + key := fmt.Sprintf(a.cfg.SessionKeyPattern, hex.EncodeToString(digest[:])) + data, err := a.redis.Get(ctx, key).Bytes() + if err != nil && errors.Is(err, redis.Nil) { + return nil, ErrAuthRequired + } + if err != nil { + return nil, err + } + var session Session + if err := json.Unmarshal(data, &session); err != nil { + return nil, ErrAuthRequired + } + return &session, nil +} + +func bearerToken(req *http.Request) (string, error) { + authz := strings.TrimSpace(req.Header.Get("Authorization")) + token, ok := strings.CutPrefix(authz, "Bearer ") + if !ok || strings.TrimSpace(token) == "" { + return "", ErrAuthRequired + } + return strings.TrimSpace(token), nil +} + +func principalFromDevHeaders(req *http.Request) *Principal { + uid := parseInt64Header(req, "X-User-ID", 0) + if uid <= 0 { + return nil + } + return &Principal{ + TenantID: parseInt64Header(req, "X-Tenant-ID", 1), + TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"), + UserID: uid, + DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID")), + AuthLevel: "auth", + } +} + +func parseInt64Header(req *http.Request, key string, fallback int64) int64 { + value := strings.TrimSpace(req.Header.Get(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fallback + } + return parsed +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a9a3cdc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,178 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Listen string `json:"listen"` + MySQL MySQLConfig `json:"mysql"` + Redis RedisConfig `json:"redis"` + Kafka KafkaConfig `json:"kafka"` + Auth AuthConfig `json:"auth"` + ReadTimeout time.Duration `json:"-"` + WriteTimeout time.Duration `json:"-"` +} + +type MySQLConfig struct { + DSN string `json:"dsn"` +} + +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"` + 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"` +} + +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)/tinode?parseTime=true&charset=utf8mb4,utf8&loc=Local", + }, + 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", + RequiredTenantCode: true, + RequiredAPIKey: true, + }, + 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") + 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.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR") + 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 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_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") + } + 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") + } + return nil +} + +func setString(target *string, key string) { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + *target = value + } +} + +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 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 +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..cffcf2b --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,181 @@ +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{ + Topic: p.topic, + 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) +} diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go new file mode 100644 index 0000000..95262d0 --- /dev/null +++ b/internal/httpapi/api.go @@ -0,0 +1,974 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/events" + "git.118im.com/im_team/im_workbench/internal/store" +) + +type API struct { + store *store.Store + auth *auth.Authenticator + events events.Publisher +} + +func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API { + if publisher == nil { + publisher = events.NoopPublisher{} + } + return &API{store: st, auth: authenticator, events: publisher} +} + +func (a *API) Register(mux *http.ServeMux) { + mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench)) + mux.HandleFunc("/v1/workbench/organization/departments", a.withAuth(a.organizationDepartments)) + mux.HandleFunc("/v1/workbench/organization/departments/", a.withAuth(a.organizationDepartmentResource)) + mux.HandleFunc("/v1/workbench/organization/members", a.withAuth(a.organizationMembers)) + mux.HandleFunc("/v1/workbench/organization/search", a.withAuth(a.organizationSearch)) + mux.HandleFunc("/v1/workbench/attendance/config", a.withAuth(a.attendanceConfig)) + mux.HandleFunc("/v1/workbench/attendance/records", a.withAuth(a.attendanceRecords)) + mux.HandleFunc("/v1/workbench/attendance/checkins", a.withAuth(a.attendanceCheckins)) + mux.HandleFunc("/v1/workbench/attendance/appeals", a.withAuth(a.attendanceAppeals)) + mux.HandleFunc("/v1/workbench/meeting-notes", a.withAuth(a.meetingNotes)) + mux.HandleFunc("/v1/workbench/meetings", a.withAuth(a.meetings)) + mux.HandleFunc("/v1/workbench/meetings/", a.withAuth(a.meetingResource)) + mux.HandleFunc("/v1/workbench/reports/daily/statistics", a.withAuth(a.dailyReportStatistics)) + mux.HandleFunc("/v1/workbench/reports/daily/reminders", a.withAuth(a.dailyReportReminders)) + mux.HandleFunc("/v1/workbench/reports/daily", a.withAuth(a.dailyReports)) + mux.HandleFunc("/v1/workbench/reports/daily/", a.withAuth(a.dailyReportResource)) + mux.HandleFunc("/v1/workbench/reports/monthly/statistics", a.withAuth(a.monthlyReportStatistics)) + mux.HandleFunc("/v1/workbench/reports/monthly", a.withAuth(a.monthlyReports)) + mux.HandleFunc("/v1/workbench/reports/monthly/", a.withAuth(a.monthlyReportResource)) + mux.HandleFunc("/v1/workbench/executive-mailbox/config", a.withAuth(a.mailboxConfig)) + mux.HandleFunc("/v1/workbench/executive-mailbox/submissions", a.withAuth(a.mailboxSubmissions)) +} + +func (a *API) Healthz(w http.ResponseWriter, r *http.Request) { + writeSuccess(w, r, map[string]any{"status": "ok", "service": "im_workbench"}) +} + +type handlerFunc func(http.ResponseWriter, *http.Request, *auth.Principal) + +func (a *API) withAuth(next handlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + principal, err := a.auth.Authenticate(r) + if err != nil { + switch err { + case auth.ErrForbidden: + writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) + default: + writeError(w, r, http.StatusUnauthorized, 30043, reasonAuthRequired, "authentication required", nil) + } + return + } + next(w, r, principal) + } +} + +func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + modules, version, err := a.store.WorkbenchModules(r.Context(), p.UserID) + if err != nil { + writeStoreError(w, r, err) + return + } + items := workbenchModulePayloads(modules) + if err := a.populateUnread(r, p, items); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{ + "layout_version": version, + "modules": items, + "enterprise": map[string]any{ + "tenant_code": p.TenantCode, + "feature_flags": map[string]any{ + "organization": true, + "attendance": true, + "meetings": true, + "daily_reports": true, + "monthly_reports": true, + "executive_mailbox": true, + }, + }, + }) +} + +func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + parentID := strings.TrimSpace(r.URL.Query().Get("parent_id")) + if parentID == "" { + parentID = "root" + } + items, next, err := a.store.Departments(r.Context(), p.UserID, parentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(organizationDepartmentPayloads(items), next)) +} + +func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + departmentID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/organization/departments/") + if !ok || tail != "members" { + writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "organization route not found", nil) + return + } + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + items, next, err := a.store.DepartmentMembers(r.Context(), p.UserID, departmentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + payload := pagePayload(organizationMemberPayloads(items), next) + payload["department_id"] = departmentID + writeSuccess(w, r, payload) +} + +func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")) + items, next, err := a.store.Members(r.Context(), p.UserID, r.URL.Query().Get("department_id"), strings.TrimSpace(query), r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + payload := pagePayload(organizationMemberPayloads(items), next) + payload["department_id"] = strings.TrimSpace(r.URL.Query().Get("department_id")) + writeSuccess(w, r, payload) +} + +func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) + return + } + departments, _, err := a.store.Departments(r.Context(), p.UserID, "root", "", limit(r, 10, 50)) + if err != nil { + writeStoreError(w, r, err) + return + } + members, _, err := a.store.Members(r.Context(), p.UserID, "", query, "", limit(r, 20, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + items := make([]map[string]any, 0, len(departments)+len(members)) + for _, department := range departments { + if strings.Contains(department.Name, query) { + items = append(items, map[string]any{"type": "department", "department": organizationDepartmentPayload(department)}) + } + } + for _, member := range members { + items = append(items, map[string]any{"type": "user", "user": organizationMemberPayload(member)}) + } + writeSuccess(w, r, map[string]any{"items": items, "next_cursor": "", "has_more": false}) +} + +func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + date, err := parseDate(firstNonEmpty(r.URL.Query().Get("date"), time.Now().Format("2006-01-02"))) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + cfg, err := a.store.AttendanceConfig(r.Context(), p.UserID, date) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, attendanceConfigPayload(cfg)) +} + +func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + records, next, stats, err := a.store.AttendanceRecords(r.Context(), p.UserID, store.AttendanceListOptions{ + Scope: r.URL.Query().Get("scope"), + From: r.URL.Query().Get("from"), + To: r.URL.Query().Get("to"), + Date: r.URL.Query().Get("date"), + Status: r.URL.Query().Get("status"), + Cursor: r.URL.Query().Get("cursor"), + Limit: limit(r, 50, 100), + UserID: parseInt64(r.URL.Query().Get("user_id")), + }) + if err != nil { + writeStoreError(w, r, err) + return + } + payload := pagePayload(attendanceRecordPayloads(records), next) + payload["statistics"] = attendanceStatsPayload(stats) + payload["permissions"] = map[string]any{"can_checkin": true, "can_view_managed": true, "can_appeal": true} + writeSuccess(w, r, payload) +} + +func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + Date string `json:"date"` + Action string `json:"action"` + RuleID string `json:"rule_id"` + ShiftID string `json:"shift_id"` + ClientTime string `json:"client_time"` + Location string `json:"location"` + Wifi string `json:"wifi"` + Device string `json:"device"` + } + if !decodeBody(w, r, &body) { + return + } + date, err := parseDate(body.Date) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + clientTime, err := parseTime(body.ClientTime) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"}) + return + } + record, checkin, err := a.store.CreateAttendanceCheckin(r.Context(), p.UserID, "attc_"+randomToken(12), store.AttendanceCheckinRequest{ + Date: date, Action: strings.TrimSpace(body.Action), RuleID: strings.TrimSpace(body.RuleID), ShiftID: strings.TrimSpace(body.ShiftID), + ClientTime: clientTime, Location: strings.TrimSpace(body.Location), Wifi: strings.TrimSpace(body.Wifi), Device: strings.TrimSpace(body.Device), IdempotencyKey: idempotencyKey, + }) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "attendance", "attendance.record.updated", record.RecordID, + []int64{p.UserID}, "打卡", record.Date.Format("2006-01-02"), + map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status}) { + return + } + writeSuccess(w, r, map[string]any{"record": attendanceRecordPayload(record), "checkin": attendanceCheckinPayload(*checkin)}) +} + +func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + switch r.Method { + case http.MethodGet: + items, next, err := a.store.AttendanceAppeals(r.Context(), p.UserID, r.URL.Query().Get("status"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(attendanceAppealPayloads(items), next)) + case http.MethodPost: + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + RecordID string `json:"record_id"` + AppealType string `json:"appeal_type"` + Reason string `json:"reason"` + TargetAction string `json:"target_action"` + TargetTime string `json:"target_time"` + AttachmentFileIDs []string `json:"attachment_file_ids"` + } + if !decodeBody(w, r, &body) { + return + } + var targetTime *time.Time + if strings.TrimSpace(body.TargetTime) != "" { + parsed, err := parseTime(body.TargetTime) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"}) + return + } + targetTime = &parsed + } + attachments, _ := json.Marshal(body.AttachmentFileIDs) + appeal, err := a.store.CreateAttendanceAppeal(r.Context(), p.UserID, store.AttendanceAppeal{ + AppealID: "atta_" + randomToken(12), RecordID: strings.TrimSpace(body.RecordID), UserID: p.UserID, AppealType: strings.TrimSpace(body.AppealType), + Reason: strings.TrimSpace(body.Reason), TargetAction: strings.TrimSpace(body.TargetAction), TargetTime: targetTime, AttachmentFileIDs: string(attachments), + }, idempotencyKey) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "attendance", "attendance.appeal.created", appeal.AppealID, + []int64{p.UserID}, "考勤申诉", appeal.RecordID, + map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status}) { + return + } + writeSuccess(w, r, attendanceAppealPayload(appeal)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + switch r.Method { + case http.MethodGet: + items, next, err := a.store.Meetings(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(meetingPayloads(items, p.UserID), next)) + case http.MethodPost: + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + Title string `json:"title"` + RecorderUserID int64 `json:"recorder_user_id"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` + Location string `json:"location"` + AttendeeUserIDs []int64 `json:"attendee_user_ids"` + ReminderMinutes []int `json:"reminder_minutes"` + } + if !decodeBody(w, r, &body) { + return + } + startAt, err := parseTime(body.StartAt) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) + return + } + endAt, err := parseTime(body.EndAt) + if err != nil || !endAt.After(startAt) { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) + return + } + reminders, _ := json.Marshal(body.ReminderMinutes) + meeting, err := a.store.CreateMeeting(r.Context(), p.UserID, store.Meeting{ + MeetingID: "mtg_" + randomToken(12), RecorderID: body.RecorderUserID, Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, + Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey, + }, body.AttendeeUserIDs) + if err != nil { + writeStoreError(w, r, err) + return + } + targets := meetingTargetUsers(*meeting) + if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.created", meeting.MeetingID, + targets, "会议通知", meeting.Title, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) { + return + } + writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + meetingID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/meetings/") + if !ok { + writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "meeting route not found", nil) + return + } + switch { + case tail == "" && r.Method == http.MethodGet: + meeting, err := a.store.Meeting(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) + case tail == "" && r.Method == http.MethodPatch: + var body struct { + Title *string `json:"title"` + RecorderUserID *int64 `json:"recorder_user_id"` + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + meeting, err := a.store.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID, Version: body.Version}) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.updated", meeting.MeetingID, + meetingTargetUsers(*meeting), "会议变更", meeting.Title, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) { + return + } + writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) + case tail == "cancel" && r.Method == http.MethodPost: + meeting, err := a.store.CancelMeeting(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.canceled", meeting.MeetingID, + meetingTargetUsers(*meeting), "会议取消", meeting.Title, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) { + return + } + writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) + case tail == "my-response" && r.Method == http.MethodPut: + var body struct { + Response string `json:"response"` + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + meeting, err := a.store.RespondMeeting(r.Context(), p.UserID, meetingID, strings.TrimSpace(body.Response), body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.response.updated", meeting.MeetingID, + []int64{meeting.OrganizerID, meeting.RecorderID}, "会议回执", meeting.Title, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "response": strings.TrimSpace(body.Response)}) { + return + } + writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) + case tail == "notes" && r.Method == http.MethodGet: + a.meetingNotesDetail(w, r, p, meetingID) + case tail == "notes/files" && r.Method == http.MethodPost: + a.meetingNoteFiles(w, r, p, meetingID) + case strings.HasPrefix(tail, "notes/files/"): + a.meetingNoteFileResource(w, r, p, meetingID, strings.TrimPrefix(tail, "notes/files/")) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + items, next, err := a.store.MeetingNotes(r.Context(), p.UserID, r.URL.Query().Get("query"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(meetingNoteListPayloads(items, p.UserID), next)) +} + +func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID string) { + meeting, err := a.store.Meeting(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + files, err := a.store.MeetingNoteFiles(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, meetingNotesDetailPayload(*meeting, files, p.UserID)) +} + +func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID string) { + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + FileID string `json:"file_id"` + } + if !decodeBody(w, r, &body) { + return + } + file, err := a.store.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), strings.TrimSpace(body.FileID), idempotencyKey) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.created", file.NoteFileID, + []int64{p.UserID}, "会议笔记", file.FileName, + map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) { + return + } + writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID)) +} + +func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID, noteFileID string) { + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodPut: + var body struct { + FileID string `json:"file_id"` + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + file, err := a.store.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, strings.TrimSpace(body.FileID), idempotencyKey, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.replaced", file.NoteFileID, + []int64{p.UserID}, "会议笔记", file.FileName, + map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) { + return + } + writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID)) + case http.MethodDelete: + version, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("version"))) + if err != nil || version <= 0 { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) + return + } + file, err := a.store.DeleteMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, idempotencyKey, version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.deleted", file.NoteFileID, + []int64{p.UserID}, "会议笔记", file.FileName, + map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) { + return + } + writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + switch r.Method { + case http.MethodGet: + items, next, err := a.store.DailyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(dailyReportPayloads(items), next)) + case http.MethodPost: + var body struct { + ReportDate string `json:"report_date"` + } + if !decodeBody(w, r, &body) { + return + } + date, err := parseDate(body.ReportDate) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) + return + } + report, err := a.store.CreateDailyDraft(r.Context(), p.UserID, "dr_"+randomToken(12), date) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.created", report.ReportID, + []int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"), + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, dailyReportPayload(*report)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/daily/") + if !ok { + writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "daily report route not found", nil) + return + } + switch { + case tail == "" && r.Method == http.MethodGet: + report, err := a.store.DailyReport(r.Context(), p.UserID, reportID) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.saved", report.ReportID, + []int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"), + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, dailyReportPayload(*report)) + case tail == "draft" && r.Method == http.MethodPut: + var body struct { + Content string `json:"content"` + Attachments []string `json:"attachments"` + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.SaveDailyDraft(r.Context(), p.UserID, reportID, body.Content, body.Attachments, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.submitted", report.ReportID, + []int64{p.UserID}, "日报已提交", report.ReportDate.Format("2006-01-02"), + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, dailyReportPayload(*report)) + case tail == "submit" && r.Method == http.MethodPost: + var body struct { + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.SubmitDaily(r.Context(), p.UserID, reportID, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, dailyReportPayload(*report)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) +} + +func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + Date string `json:"date"` + UserIDs []int64 `json:"user_ids"` + } + if !decodeBody(w, r, &body) { + return + } + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.reminded", strings.TrimSpace(body.Date), + body.UserIDs, "日报提醒", strings.TrimSpace(body.Date), + map[string]any{"date": strings.TrimSpace(body.Date), "idempotency_key": idempotencyKey, "reminded_count": len(body.UserIDs)}) { + return + } + writeSuccess(w, r, map[string]any{"status": "queued"}) +} + +func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + switch r.Method { + case http.MethodGet: + items, next, err := a.store.MonthlyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(monthlyReportPayloads(items), next)) + case http.MethodPost: + var body struct { + Month string `json:"month"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.CreateMonthlyDraft(r.Context(), p.UserID, "mr_"+randomToken(12), strings.TrimSpace(body.Month)) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.created", report.ReportID, + []int64{p.UserID}, "月报", report.Month, + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, monthlyReportPayload(*report)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/monthly/") + if !ok { + writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "monthly report route not found", nil) + return + } + switch { + case tail == "" && r.Method == http.MethodGet: + report, err := a.store.MonthlyReport(r.Context(), p.UserID, reportID) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.saved", report.ReportID, + []int64{p.UserID}, "月报", report.Month, + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, monthlyReportPayload(*report)) + case tail == "draft" && r.Method == http.MethodPut: + var body struct { + WorkSummary string `json:"work_summary"` + NextPlan string `json:"next_plan"` + Attachments []string `json:"attachments"` + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.SaveMonthlyDraft(r.Context(), p.UserID, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.submitted", report.ReportID, + []int64{p.UserID}, "月报已提交", report.Month, + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, monthlyReportPayload(*report)) + case tail == "submit" && r.Method == http.MethodPost: + var body struct { + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.SubmitMonthly(r.Context(), p.UserID, reportID, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.reopened", report.ReportID, + []int64{p.UserID}, "月报已重开", report.Month, + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } + writeSuccess(w, r, monthlyReportPayload(*report)) + case tail == "reopen" && r.Method == http.MethodPost: + var body struct { + Version int `json:"version"` + } + if !decodeBody(w, r, &body) { + return + } + report, err := a.store.ReopenMonthly(r.Context(), p.UserID, reportID, body.Version) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, monthlyReportPayload(*report)) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) +} + +func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + cfg, err := a.store.MailboxConfig(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, mailboxConfigPayload(*cfg)) +} + +func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + idempotencyKey, ok := requireIdempotencyKey(w, r) + if !ok { + return + } + var body struct { + Subject string `json:"subject"` + Content string `json:"content"` + Anonymous bool `json:"anonymous"` + AttachmentFileIDs []string `json:"attachment_file_ids"` + NoticeVersion string `json:"notice_version"` + } + if !decodeBody(w, r, &body) { + return + } + attachments, _ := json.Marshal(body.AttachmentFileIDs) + submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ + SubmissionID: "mb_" + randomToken(12), Subject: strings.TrimSpace(body.Subject), Content: strings.TrimSpace(body.Content), + Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion), + }, idempotencyKey) + if err != nil { + writeStoreError(w, r, err) + return + } + if !a.publishWorkbenchEvent(w, r, p, "mailbox", "mailbox.submitted", submission.SubmissionID, + []int64{p.UserID}, "总经理信箱", submission.Subject, + map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous}) { + return + } + writeSuccess(w, r, mailboxSubmissionPayload(*submission)) +} + +func (a *API) publishWorkbenchEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) bool { + if a.events == nil { + return true + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + event := events.NewWorkbenchEvent(p, requestID(r), module, action, resourceID, targets, title, summary, data) + if err := a.events.Publish(ctx, event); err != nil { + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "workbench event publish failed", map[string]any{"event": action}) + return false + } + return true +} + +func meetingTargetUsers(meeting store.Meeting) []int64 { + targets := []int64{meeting.OrganizerID, meeting.RecorderID} + for _, participant := range meeting.Participants { + targets = append(targets, participant.UserID) + } + return targets +} + +func (a *API) populateUnread(r *http.Request, p *auth.Principal, modules []map[string]any) error { + for _, module := range modules { + if module["code"] == "meetings" { + count, err := a.store.MeetingUnreadCount(r.Context(), p.UserID) + if err != nil { + return err + } + module["unread_count"] = count + } + } + return nil +} + +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + writeError(w, r, http.StatusMethodNotAllowed, 30002, reasonMethodNotAllowed, "method not allowed", nil) +} + +func requireIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) { + key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if key == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) + return "", false + } + return key, true +} + +func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) + return false + } + return true +} + +func limit(r *http.Request, fallback, max int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit"))) + if err != nil || parsed <= 0 { + return fallback + } + if parsed > max { + return max + } + return parsed +} + +func parseInt64(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} + +func parseDate(value string) (time.Time, error) { + return time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local) +} + +func parseTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("empty time") + } + if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { + return parsed, nil + } + return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) +} + +func randomToken(size int) string { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func resourceTail(path, prefix string) (string, string, bool) { + trimmed := strings.TrimPrefix(path, prefix) + if trimmed == path || trimmed == "" { + return "", "", false + } + parts := strings.SplitN(trimmed, "/", 2) + id := strings.TrimSpace(parts[0]) + tail := "" + if len(parts) == 2 { + tail = strings.Trim(parts[1], "/") + } + return id, tail, id != "" +} + +func pagePayload(items []map[string]any, next string) map[string]any { + return map[string]any{"items": items, "next_cursor": next, "has_more": next != ""} +} diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go new file mode 100644 index 0000000..e8d8df8 --- /dev/null +++ b/internal/httpapi/errors.go @@ -0,0 +1,9 @@ +package httpapi + +import "errors" + +var ( + ErrNotFound = errors.New("resource not found") + ErrForbidden = errors.New("forbidden") + ErrConflict = errors.New("conflict") +) diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go new file mode 100644 index 0000000..b80f482 --- /dev/null +++ b/internal/httpapi/payload.go @@ -0,0 +1,474 @@ +package httpapi + +import ( + "encoding/json" + "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": 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": 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": checkin.Location, + "wifi": checkin.Wifi, + "device": 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": 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 + } + data := map[string]any{ + "meeting_id": meeting.MeetingID, + "title": meeting.Title, + "organizer_id": meeting.OrganizerID, + "organizer": userSummary(meeting.OrganizerID), + "recorder_user_id": recorderID, + "recorder": userSummary(recorderID), + "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": meeting.State, + "reminder_minutes": jsonList(meeting.ReminderMinutes), + "version": meeting.Version, + "participants": participants, + "participant_count": len(participants), + "participant_names": []string{}, + "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 { + item := map[string]any{"user_id": participant.UserID, "response": participant.Response} + 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 { + return map[string]any{ + "note_file_id": file.NoteFileID, + "meeting_id": file.MeetingID, + "file_id": file.FileID, + "file_name": file.FileName, + "mime_type": file.MIMEType, + "file_size": file.FileSize, + "file_etag": file.FileETag, + "status": file.Status, + "preview_status": file.PreviewStatus, + "preview_mode": file.PreviewMode, + "preview_file_id": file.PreviewFileID, + "uploader_user_id": file.UploaderID, + "uploader": userSummary(file.UploaderID), + "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 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": 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": 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 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": 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 := meeting.State == "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 + } + canWrite := 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) map[string]any { + return map[string]any{"user_id": userID, "display_name": userID} +} + +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") +} diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go new file mode 100644 index 0000000..e58b44f --- /dev/null +++ b/internal/httpapi/response.go @@ -0,0 +1,86 @@ +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" + 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) + 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 "" +} diff --git a/internal/store/errors.go b/internal/store/errors.go new file mode 100644 index 0000000..4be978f --- /dev/null +++ b/internal/store/errors.go @@ -0,0 +1,9 @@ +package store + +import "errors" + +var ( + ErrNotFound = errors.New("resource not found") + ErrForbidden = errors.New("forbidden") + ErrConflict = errors.New("conflict") +) diff --git a/internal/store/mysql.go b/internal/store/mysql.go new file mode 100644 index 0000000..884b57a --- /dev/null +++ b/internal/store/mysql.go @@ -0,0 +1,826 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" +) + +type Store struct { + db *sqlx.DB +} + +func Open(cfg config.MySQLConfig) (*Store, error) { + db, err := sqlx.Open("mysql", cfg.DSN) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(30 * time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) { + var modules []WorkbenchModule + err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission + FROM im_workbench_apps WHERE enabled=1 ORDER BY sort_order ASC,id ASC`) + if err != nil { + return nil, 0, err + } + for i := range modules { + modules[i].UnreadCount = 0 + if modules[i].Permission == "" { + modules[i].Permission = "enabled" + } + } + return modules, 1, nil +} + +func (s *Store) MeetingUnreadCount(ctx context.Context, userID int64) (int, error) { + var count int + err := s.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM im_system_notifications + WHERE user_id=? AND read_at IS NULL AND event_type LIKE 'meeting.%'`, userID) + if isMissingTable(err) { + return 0, nil + } + return count, err +} + +func (s *Store) Departments(ctx context.Context, userID int64, parentID, cursor string, limit int) ([]OrganizationDepartment, string, error) { + if parentID == "" { + parentID = "root" + } + var items []OrganizationDepartment + query := `SELECT d.department_id,d.name,d.parent_id, + (SELECT COUNT(*) FROM im_org_departments c WHERE c.parent_id=d.department_id) AS child_count, + (SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id AND m.status='active') AS member_count, + d.version + FROM im_org_departments d WHERE d.parent_id=?` + args := []any{parentID} + if cursor != "" { + query += ` AND d.department_id>?` + args = append(args, cursor) + } + query += ` ORDER BY d.department_id ASC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + return trimDepartmentPage(items, limit) +} + +func (s *Store) DepartmentMembers(ctx context.Context, userID int64, departmentID, cursor string, limit int) ([]OrganizationMember, string, error) { + var items []OrganizationMember + query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, + IFNULL(avatar_file_id,'') AS avatar_file_id,status,contactable,version + FROM im_org_members WHERE department_id=? AND status='active'` + args := []any{departmentID} + if cursor != "" { + query += ` AND user_id>?` + args = append(args, cursor) + } + query += ` ORDER BY user_id ASC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + return trimMemberPage(items, limit) +} + +func (s *Store) Members(ctx context.Context, userID int64, departmentID, keyword, cursor string, limit int) ([]OrganizationMember, string, error) { + var items []OrganizationMember + where := []string{"status='active'"} + args := []any{} + if departmentID != "" { + where = append(where, "department_id=?") + args = append(args, departmentID) + } + if keyword != "" { + where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + if cursor != "" { + where = append(where, "user_id>?") + args = append(args, cursor) + } + query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, + IFNULL(avatar_file_id,'') AS avatar_file_id,status,contactable,version + FROM im_org_members WHERE ` + strings.Join(where, " AND ") + ` ORDER BY user_id ASC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + return trimMemberPage(items, limit) +} + +func (s *Store) AttendanceConfig(ctx context.Context, userID int64, date time.Time) (*AttendanceConfig, error) { + var rule AttendanceRule + if err := s.db.GetContext(ctx, &rule, `SELECT rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,appeal_deadline + FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: AttendanceRule{}, Shifts: []AttendanceShift{}}, nil + } + return nil, err + } + var shifts []AttendanceShift + if err := s.db.SelectContext(ctx, &shifts, `SELECT shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end + FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, rule.RuleID); err != nil { + return nil, err + } + record, _ := s.attendanceRecordByDate(ctx, userID, date) + return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: rule, Shifts: shifts, TodayRecord: record}, nil +} + +func (s *Store) AttendanceRecords(ctx context.Context, userID int64, opts AttendanceListOptions) ([]AttendanceRecord, string, *AttendanceStats, error) { + where := []string{"1=1"} + args := []any{} + if opts.UserID > 0 { + where = append(where, "user_id=?") + args = append(args, opts.UserID) + } else if opts.Scope != "managed" { + where = append(where, "user_id=?") + args = append(args, userID) + } + if opts.Date != "" { + where = append(where, "record_date=?") + args = append(args, opts.Date) + } + if opts.From != "" { + where = append(where, "record_date>=?") + args = append(args, opts.From) + } + if opts.To != "" { + where = append(where, "record_date<=?") + args = append(args, opts.To) + } + if opts.Status != "" { + where = append(where, "status=?") + args = append(args, opts.Status) + } + if opts.Cursor != "" { + where = append(where, "id opts.Limit { + next = records[opts.Limit-1].RecordID + records = records[:opts.Limit] + } + for i := range records { + records[i].Checkins, _ = s.attendanceCheckins(ctx, records[i].RecordID) + } + stats := &AttendanceStats{ExpectedCount: len(records), CheckedCount: len(records)} + return records, next, stats, nil +} + +type AttendanceListOptions struct { + Scope string + From string + To string + Date string + Status string + Cursor string + Limit int + UserID int64 +} + +func (s *Store) CreateAttendanceCheckin(ctx context.Context, userID int64, checkinID string, req AttendanceCheckinRequest) (*AttendanceRecord, *AttendanceCheckin, error) { + now := time.Now().UTC().Round(time.Millisecond) + recordID := fmt.Sprintf("att_%d_%s_%s", userID, req.Date.Format("20060102"), req.ShiftID) + result := "normal" + status := "checked_in" + if req.Action == "check_out" { + status = "normal" + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, nil, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,'[]','none',1,?,?) + ON DUPLICATE KEY UPDATE status=VALUES(status),version=version+1,updated_at=VALUES(updated_at)`, + recordID, userID, req.Date, req.RuleID, req.ShiftID, status, now, now) + if err != nil { + return nil, nil, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + checkinID, recordID, userID, req.Date, req.Action, req.RuleID, req.ShiftID, req.ClientTime, now, result, req.Location, req.Wifi, req.Device, req.IdempotencyKey) + if isDuplicate(err) { + _ = tx.Rollback() + return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey) + } + if err != nil { + return nil, nil, err + } + if err := tx.Commit(); err != nil { + return nil, nil, err + } + return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey) +} + +type AttendanceCheckinRequest struct { + Date time.Time + Action string + RuleID string + ShiftID string + ClientTime time.Time + Location string + Wifi string + Device string + IdempotencyKey string +} + +func (s *Store) AttendanceAppeals(ctx context.Context, userID int64, status, cursor string, limit int) ([]AttendanceAppeal, string, error) { + where := []string{"user_id=?"} + args := []any{userID} + if status != "" { + where = append(where, "status=?") + args = append(args, status) + } + if cursor != "" { + where = append(where, "id limit { + next = items[limit-1].AppealID + items = items[:limit] + } + return items, next, nil +} + +func (s *Store) CreateAttendanceAppeal(ctx context.Context, userID int64, appeal AttendanceAppeal, idempotencyKey string) (*AttendanceAppeal, error) { + now := time.Now().UTC().Round(time.Millisecond) + if appeal.Status == "" { + appeal.Status = "pending" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,1,?,?,?,?)`, + appeal.AppealID, appeal.RecordID, userID, appeal.AppealType, appeal.Reason, appeal.TargetAction, appeal.TargetTime, appeal.AttachmentFileIDs, appeal.Status, idempotencyKey, now, now, now) + if isDuplicate(err) { + var existing AttendanceAppeal + if getErr := s.db.GetContext(ctx, &existing, `SELECT appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,submitted_at,created_at,updated_at + FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=? LIMIT 1`, userID, idempotencyKey); getErr != nil { + return nil, getErr + } + return &existing, nil + } + if err != nil { + return nil, err + } + return &appeal, nil +} + +func (s *Store) Meetings(ctx context.Context, userID int64, cursor string, limit int) ([]Meeting, string, error) { + var items []Meeting + query := `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at + FROM im_meetings WHERE organizer_id=? OR EXISTS(SELECT 1 FROM im_meeting_participants p WHERE p.meeting_id=im_meetings.meeting_id AND p.user_id=?)` + args := []any{userID, userID} + if cursor != "" { + query += ` AND meeting_id>?` + args = append(args, cursor) + } + query += ` ORDER BY start_at DESC,id DESC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + next = items[limit-1].MeetingID + items = items[:limit] + } + for i := range items { + items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID) + } + return items, next, nil +} + +func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) { + var meeting Meeting + if err := s.db.GetContext(ctx, &meeting, `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at + FROM im_meetings WHERE meeting_id=? LIMIT 1`, meetingID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID) + if !meetingVisible(meeting, userID) { + return nil, ErrForbidden + } + return &meeting, nil +} + +func (s *Store) CreateMeeting(ctx context.Context, userID int64, meeting Meeting, attendees []int64) (*Meeting, error) { + now := time.Now().UTC().Round(time.Millisecond) + if meeting.RecorderID == 0 { + meeting.RecorderID = userID + } + if meeting.State == "" { + meeting.State = "scheduled" + } + reminders := meeting.ReminderMinutes + if reminders == "" { + reminders = "[]" + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,1,?,?)`, meeting.MeetingID, userID, meeting.RecorderID, meeting.Title, meeting.StartAt, meeting.EndAt, meeting.Location, meeting.State, reminders, meeting.IdempotencyKey, now, now) + if isDuplicate(err) && meeting.IdempotencyKey != "" { + _ = tx.Rollback() + var existingID string + if getErr := s.db.GetContext(ctx, &existingID, `SELECT meeting_id FROM im_meetings WHERE organizer_id=? AND idempotency_key=? LIMIT 1`, userID, meeting.IdempotencyKey); getErr != nil { + return nil, getErr + } + return s.Meeting(ctx, userID, existingID) + } + if err != nil { + return nil, err + } + seen := map[int64]bool{userID: true} + attendees = append(attendees, userID, meeting.RecorderID) + for _, uid := range attendees { + if uid <= 0 || seen[uid] { + continue + } + seen[uid] = true + _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(meeting_id,user_id,response) VALUES(?,?,?)`, meeting.MeetingID, uid, "pending") + if err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.Meeting(ctx, userID, meeting.MeetingID) +} + +func (s *Store) UpdateMeeting(ctx context.Context, userID int64, meetingID string, patch MeetingPatch) (*Meeting, error) { + meeting, err := s.Meeting(ctx, userID, meetingID) + if err != nil { + return nil, err + } + if meeting.OrganizerID != userID || meeting.State != "scheduled" || patch.Version != meeting.Version { + return nil, ErrConflict + } + sets := []string{"version=version+1", "updated_at=?"} + args := []any{time.Now().UTC().Round(time.Millisecond)} + if patch.Title != nil { + sets = append(sets, "title=?") + args = append(args, strings.TrimSpace(*patch.Title)) + } + if patch.RecorderID != nil { + sets = append(sets, "recorder_user_id=?") + args = append(args, *patch.RecorderID) + } + args = append(args, meetingID) + if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=?`, args...); err != nil { + return nil, err + } + return s.Meeting(ctx, userID, meetingID) +} + +type MeetingPatch struct { + Title *string + RecorderID *int64 + Version int +} + +func (s *Store) CancelMeeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) { + now := time.Now().UTC().Round(time.Millisecond) + if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET state='canceled',canceled_at=?,version=version+1,updated_at=? WHERE meeting_id=? AND organizer_id=? AND state='scheduled'`, now, now, meetingID, userID); err != nil { + return nil, err + } + return s.Meeting(ctx, userID, meetingID) +} + +func (s *Store) RespondMeeting(ctx context.Context, userID int64, meetingID, response string, version int) (*Meeting, error) { + now := time.Now().UTC().Round(time.Millisecond) + if _, err := s.db.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=? WHERE meeting_id=? AND user_id=?`, response, now, meetingID, userID); err != nil { + return nil, err + } + return s.Meeting(ctx, userID, meetingID) +} + +func (s *Store) MeetingNotes(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]Meeting, string, error) { + items, next, err := s.Meetings(ctx, userID, cursor, limit) + if err != nil { + return nil, "", err + } + filtered := items[:0] + now := time.Now() + for _, item := range items { + if item.EndAt.Before(now) && item.State != "canceled" && (queryText == "" || strings.Contains(item.Title, queryText)) { + filtered = append(filtered, item) + } + } + return filtered, next, nil +} + +func (s *Store) MeetingNoteFiles(ctx context.Context, userID int64, meetingID string) ([]MeetingNoteFile, error) { + if _, err := s.Meeting(ctx, userID, meetingID); err != nil { + return nil, err + } + var files []MeetingNoteFile + if err := s.db.SelectContext(ctx, &files, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at + FROM im_meeting_note_files WHERE meeting_id=? AND status='active' ORDER BY id DESC`, meetingID); err != nil { + return nil, err + } + return files, nil +} + +func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, fileID, idempotencyKey string) (*MeetingNoteFile, error) { + meeting, err := s.Meeting(ctx, userID, meetingID) + if err != nil { + return nil, err + } + if meeting.RecorderID != userID && meeting.OrganizerID != userID { + return nil, ErrForbidden + } + now := time.Now().UTC().Round(time.Millisecond) + fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), "" + _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag) + _, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,idempotency_key,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now) + if isDuplicate(err) && idempotencyKey != "" { + var existing MeetingNoteFile + if getErr := s.db.GetContext(ctx, &existing, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at + FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, userID, idempotencyKey); getErr != nil { + return nil, getErr + } + return &existing, nil + } + if err != nil { + return nil, err + } + var created MeetingNoteFile + if err := s.db.GetContext(ctx, &created, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at + FROM im_meeting_note_files WHERE note_file_id=? LIMIT 1`, noteFileID); err != nil { + return nil, err + } + return &created, nil +} + +func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*MeetingNoteFile, error) { + meeting, err := s.Meeting(ctx, userID, meetingID) + if err != nil { + return nil, err + } + if meeting.RecorderID != userID && meeting.OrganizerID != userID { + return nil, ErrForbidden + } + fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), "" + _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag) + res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,preview_status='processing',preview_file_id='',version=version+1,idempotency_key=?,updated_at=? + WHERE meeting_id=? AND note_file_id=? AND status='active' AND version=?`, + fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), meetingID, noteFileID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.meetingNoteFile(ctx, meetingID, noteFileID) +} + +func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) { + meeting, err := s.Meeting(ctx, userID, meetingID) + if err != nil { + return nil, err + } + if meeting.RecorderID != userID && meeting.OrganizerID != userID { + return nil, ErrForbidden + } + res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET status='deleted',version=version+1,idempotency_key=?,updated_at=? + WHERE meeting_id=? AND note_file_id=? AND status='active' AND version=?`, + idempotencyKey, time.Now().UTC().Round(time.Millisecond), meetingID, noteFileID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.meetingNoteFile(ctx, meetingID, noteFileID) +} + +func (s *Store) DailyReports(ctx context.Context, userID int64, cursor string, limit int) ([]DailyReport, string, error) { + var items []DailyReport + query := `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_daily_reports WHERE author_id=?` + args := []any{userID} + if cursor != "" { + query += ` AND report_id>?` + args = append(args, cursor) + } + query += ` ORDER BY report_date DESC,id DESC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + next = items[limit-1].ReportID + items = items[:limit] + } + return items, next, nil +} + +func (s *Store) CreateDailyDraft(ctx context.Context, userID int64, reportID string, reportDate time.Time) (*DailyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + _, err := s.db.ExecContext(ctx, `INSERT INTO im_daily_reports(report_id,author_id,report_date,content,attachments,status,read_state,version,created_at,updated_at) + VALUES(?,?,?,'','[]','draft','unread',1,?,?)`, reportID, userID, reportDate, now, now) + if isDuplicate(err) { + return nil, ErrConflict + } + if err != nil { + return nil, err + } + return s.DailyReport(ctx, userID, reportID) +} + +func (s *Store) DailyReport(ctx context.Context, userID int64, reportID string) (*DailyReport, error) { + var report DailyReport + if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at + FROM im_daily_reports WHERE report_id=? LIMIT 1`, reportID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if report.AuthorID != userID { + return nil, ErrForbidden + } + return &report, nil +} + +func (s *Store) SaveDailyDraft(ctx context.Context, userID int64, reportID, content string, attachments []string, version int) (*DailyReport, error) { + data, _ := json.Marshal(attachments) + res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET content=?,attachments=?,version=version+1,updated_at=? WHERE report_id=? AND author_id=? AND version=?`, + content, string(data), time.Now().UTC().Round(time.Millisecond), reportID, userID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.DailyReport(ctx, userID, reportID) +} + +func (s *Store) SubmitDaily(ctx context.Context, userID int64, reportID string, version int) (*DailyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE report_id=? AND author_id=? AND version=?`, now, now, reportID, userID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.DailyReport(ctx, userID, reportID) +} + +func (s *Store) MonthlyReports(ctx context.Context, userID int64, cursor string, limit int) ([]MonthlyReport, string, error) { + var items []MonthlyReport + query := `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_monthly_reports WHERE author_id=?` + args := []any{userID} + if cursor != "" { + query += ` AND report_id>?` + args = append(args, cursor) + } + query += ` ORDER BY report_month DESC,id DESC LIMIT ?` + args = append(args, limit+1) + if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + next = items[limit-1].ReportID + items = items[:limit] + } + return items, next, nil +} + +func (s *Store) CreateMonthlyDraft(ctx context.Context, userID int64, reportID, month string) (*MonthlyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + _, err := s.db.ExecContext(ctx, `INSERT INTO im_monthly_reports(report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,created_at,updated_at) + VALUES(?,?,?,'','','[]','draft','unread',1,?,?)`, reportID, userID, month, now, now) + if isDuplicate(err) { + return nil, ErrConflict + } + if err != nil { + return nil, err + } + return s.MonthlyReport(ctx, userID, reportID) +} + +func (s *Store) MonthlyReport(ctx context.Context, userID int64, reportID string) (*MonthlyReport, error) { + var report MonthlyReport + if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at + FROM im_monthly_reports WHERE report_id=? LIMIT 1`, reportID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if report.AuthorID != userID { + return nil, ErrForbidden + } + return &report, nil +} + +func (s *Store) SaveMonthlyDraft(ctx context.Context, userID int64, reportID, summary, nextPlan string, attachments []string, version int) (*MonthlyReport, error) { + data, _ := json.Marshal(attachments) + res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET work_summary=?,next_plan=?,attachments=?,version=version+1,updated_at=? WHERE report_id=? AND author_id=? AND version=?`, + summary, nextPlan, string(data), time.Now().UTC().Round(time.Millisecond), reportID, userID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.MonthlyReport(ctx, userID, reportID) +} + +func (s *Store) SubmitMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE report_id=? AND author_id=? AND version=?`, now, now, reportID, userID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.MonthlyReport(ctx, userID, reportID) +} + +func (s *Store) ReopenMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) { + res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='draft',version=version+1,updated_at=? WHERE report_id=? AND author_id=? AND version=?`, + time.Now().UTC().Round(time.Millisecond), reportID, userID, version) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrConflict + } + return s.MonthlyReport(ctx, userID, reportID) +} + +func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) { + var cfg MailboxConfig + if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,version FROM im_mailbox_config WHERE id=1`); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &MailboxConfig{Enabled: true, AllowAnonymous: true, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", Version: 1}, nil + } + return nil, err + } + return &cfg, nil +} + +func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) { + now := time.Now().UTC().Round(time.Millisecond) + _, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now) + if isDuplicate(err) && idempotencyKey != "" { + var existing MailboxSubmission + if getErr := s.db.GetContext(ctx, &existing, `SELECT submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,status,submitted_at FROM im_mailbox_submissions WHERE sender_id=? AND idempotency_key=? LIMIT 1`, userID, idempotencyKey); getErr != nil { + return nil, getErr + } + return &existing, nil + } + if err != nil { + return nil, err + } + submission.SenderID = userID + submission.Status = "received" + submission.SubmittedAt = now + return &submission, nil +} + +func (s *Store) attendanceRecordByDate(ctx context.Context, userID int64, date time.Time) (*AttendanceRecord, error) { + var record AttendanceRecord + if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at + FROM im_attendance_records WHERE user_id=? AND record_date=? ORDER BY id DESC LIMIT 1`, userID, date.Format("2006-01-02")); err != nil { + return nil, err + } + record.Checkins, _ = s.attendanceCheckins(ctx, record.RecordID) + return &record, nil +} + +func (s *Store) attendanceCheckins(ctx context.Context, recordID string) ([]AttendanceCheckin, error) { + var checkins []AttendanceCheckin + err := s.db.SelectContext(ctx, &checkins, `SELECT checkin_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID) + return checkins, err +} + +func (s *Store) attendanceRecordAndCheckinByIdempotency(ctx context.Context, userID int64, key string) (*AttendanceRecord, *AttendanceCheckin, error) { + var checkin AttendanceCheckin + var recordID string + if err := s.db.QueryRowxContext(ctx, `SELECT checkin_id,record_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=? LIMIT 1`, userID, key). + Scan(&checkin.CheckinID, &recordID, &checkin.Action, &checkin.ClientTime, &checkin.ServerTime, &checkin.Result, &checkin.Location, &checkin.Wifi, &checkin.Device); err != nil { + return nil, nil, err + } + var record AttendanceRecord + if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at FROM im_attendance_records WHERE record_id=?`, recordID); err != nil { + return nil, nil, err + } + record.Checkins, _ = s.attendanceCheckins(ctx, recordID) + return &record, &checkin, nil +} + +func (s *Store) meetingParticipants(ctx context.Context, meetingID string) ([]MeetingParticipant, error) { + var items []MeetingParticipant + err := s.db.SelectContext(ctx, &items, `SELECT meeting_id,user_id,response,response_at FROM im_meeting_participants WHERE meeting_id=? ORDER BY id ASC`, meetingID) + return items, err +} + +func (s *Store) meetingNoteFile(ctx context.Context, meetingID, noteFileID string) (*MeetingNoteFile, error) { + var file MeetingNoteFile + if err := s.db.GetContext(ctx, &file, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at + FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &file, nil +} + +func meetingVisible(meeting Meeting, userID int64) bool { + if meeting.OrganizerID == userID || meeting.RecorderID == userID { + return true + } + for _, participant := range meeting.Participants { + if participant.UserID == userID { + return true + } + } + return false +} + +func trimDepartmentPage(items []OrganizationDepartment, limit int) ([]OrganizationDepartment, string, error) { + next := "" + if len(items) > limit { + next = items[limit-1].DepartmentID + items = items[:limit] + } + return items, next, nil +} + +func trimMemberPage(items []OrganizationMember, limit int) ([]OrganizationMember, string, error) { + next := "" + if len(items) > limit { + next = fmt.Sprintf("%d", items[limit-1].UserID) + items = items[:limit] + } + return items, next, nil +} + +func isDuplicate(err error) bool { + return err != nil && strings.Contains(err.Error(), "Duplicate entry") +} + +func isMissingTable(err error) bool { + return err != nil && strings.Contains(err.Error(), "doesn't exist") +} diff --git a/internal/store/types.go b/internal/store/types.go new file mode 100644 index 0000000..1a5ad38 --- /dev/null +++ b/internal/store/types.go @@ -0,0 +1,215 @@ +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 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"` + 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"` +} + +type MeetingNoteFile struct { + NoteFileID string `db:"note_file_id"` + MeetingID string `db:"meeting_id"` + FileID string `db:"file_id"` + UploaderID int64 `db:"uploader_id"` + 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 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 +} + +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"` +}