feat: scaffold independent workbench service
This commit is contained in:
+2
-1
@@ -3,6 +3,7 @@
|
|||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
#
|
#
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
@@ -24,6 +25,7 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
.idea/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
@@ -162,4 +164,3 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|||||||
+15
@@ -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"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: build test run
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/im_workbench ./cmd/workbench
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
||||||
@@ -1,3 +1,88 @@
|
|||||||
# im_workbench
|
# im_workbench
|
||||||
|
|
||||||
im工作台代码
|
工作台服务从 `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 审核后人工执行。
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/app"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := app.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init app: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: server.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Printf("im_workbench listening on %s", cfg.Listen)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stopCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
log.Fatalf("serve: %v", err)
|
||||||
|
case <-stopCh:
|
||||||
|
log.Printf("shutdown requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 工作台服务迁移说明
|
||||||
|
|
||||||
|
本仓库承接 `im_server` 中的工作台接口,第一阶段目标是让工作台服务独立编译、独立启动、独立鉴权,并由网关把 `/v1/workbench/*` 分流到本服务。
|
||||||
|
|
||||||
|
## 已迁移接口
|
||||||
|
|
||||||
|
| 模块 | 新路径 |
|
||||||
|
|---|---|
|
||||||
|
| 工作台入口 | `GET /v1/workbench` |
|
||||||
|
| 组织架构 | `/v1/workbench/organization/*` |
|
||||||
|
| 考勤 | `/v1/workbench/attendance/*` |
|
||||||
|
| 会议 | `/v1/workbench/meetings*` |
|
||||||
|
| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` |
|
||||||
|
| 日报 | `/v1/workbench/reports/daily*` |
|
||||||
|
| 月报 | `/v1/workbench/reports/monthly*` |
|
||||||
|
| 总经理信箱 | `/v1/workbench/executive-mailbox/*` |
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
1. 数据库暂不迁移,仍访问现有租户库工作台表。
|
||||||
|
2. 文件上传仍由 `im_server` 的 `/v0/file/u/` 处理,工作台只保存 `file_id` 引用。
|
||||||
|
3. C 端实时通知仍由 `im_server` 下发;工作台后续只投 Kafka 业务事件。
|
||||||
|
4. `im_server` 原工作台代码暂不删除,待本服务联调稳定后再清理。
|
||||||
|
5. 服务启动不自动创建或修改表结构。
|
||||||
|
|
||||||
|
## 待联调项
|
||||||
|
|
||||||
|
1. `im_server` 登录后写入 Redis session:`im:session:token:{sha256(token)}`。
|
||||||
|
2. Nginx/API Gateway 路由:`/v1/workbench/* -> im_workbench:6061`。
|
||||||
|
3. 工作台业务事件 Kafka 化:会议、考勤、日报、月报、信箱事件投递给 `im_server`。
|
||||||
|
4. 数据库专项迁移:接口稳定后再从租户库拆到工作台库。
|
||||||
|
|
||||||
|
## Kafka 事件范围
|
||||||
|
|
||||||
|
当前工作台服务生产端统一投递到 `im.workbench.event`,由 `im_server` 后续接入消费组并转为系统通知、未读数或 WS 实时下发。
|
||||||
|
|
||||||
|
| 模块 | Action | 触发场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| 考勤 | `attendance.record.updated` | 打卡成功 |
|
||||||
|
| 考勤 | `attendance.appeal.created` | 提交考勤申诉 |
|
||||||
|
| 会议 | `meeting.created` | 创建会议 |
|
||||||
|
| 会议 | `meeting.updated` | 修改会议 |
|
||||||
|
| 会议 | `meeting.canceled` | 取消会议 |
|
||||||
|
| 会议 | `meeting.response.updated` | 参会人更新回执 |
|
||||||
|
| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 |
|
||||||
|
| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 |
|
||||||
|
| 日报 | `daily_report.draft.created` | 创建日报草稿 |
|
||||||
|
| 日报 | `daily_report.draft.saved` | 保存日报草稿 |
|
||||||
|
| 日报 | `daily_report.submitted` | 提交日报 |
|
||||||
|
| 日报 | `daily_report.reminded` | 日报提醒 |
|
||||||
|
| 月报 | `monthly_report.draft.created` | 创建月报草稿 |
|
||||||
|
| 月报 | `monthly_report.draft.saved` | 保存月报草稿 |
|
||||||
|
| 月报 | `monthly_report.submitted` | 提交月报 |
|
||||||
|
| 月报 | `monthly_report.reopened` | 重开月报 |
|
||||||
|
| 信箱 | `mailbox.submitted` | 提交总经理信箱 |
|
||||||
@@ -0,0 +1,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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthRequired = errors.New("authentication required")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
TenantID int64
|
||||||
|
TenantCode string
|
||||||
|
UserID int64
|
||||||
|
DeviceID string
|
||||||
|
AuthLevel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
TenantCode string `json:"tenant_code"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
AuthLevel string `json:"auth_level"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
IssuedAt string `json:"issued_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Authenticator struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
||||||
|
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||||
|
if err := a.validateAPIKey(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.cfg.AllowDevHeaderAuth {
|
||||||
|
if principal := principalFromDevHeaders(req); principal != nil {
|
||||||
|
return principal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.cfg.SkipSessionInDev {
|
||||||
|
return &Principal{
|
||||||
|
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||||
|
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||||
|
UserID: parseInt64Header(req, "X-User-ID", 1),
|
||||||
|
AuthLevel: "auth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if a.redis == nil {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
token, err := bearerToken(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
session, err := a.sessionFromRedis(req.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if session.UserID <= 0 || session.TenantID <= 0 {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if state := strings.ToLower(strings.TrimSpace(session.State)); state != "" && state != "active" {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if session.ExpiresAt != "" {
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
|
||||||
|
if err == nil && time.Now().After(expiresAt) {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headerTenant := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
||||||
|
if a.cfg.RequiredTenantCode && headerTenant == "" {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
if headerTenant != "" && session.TenantCode != "" && headerTenant != session.TenantCode {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
return &Principal{
|
||||||
|
TenantID: session.TenantID,
|
||||||
|
TenantCode: firstNonEmpty(session.TenantCode, headerTenant),
|
||||||
|
UserID: session.UserID,
|
||||||
|
DeviceID: session.DeviceID,
|
||||||
|
AuthLevel: session.AuthLevel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) validateAPIKey(req *http.Request) error {
|
||||||
|
if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(req.Header.Get("X-MeetingIM-APIKey"))
|
||||||
|
if got == "" {
|
||||||
|
got = strings.TrimSpace(req.URL.Query().Get("apikey"))
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
if a.cfg.RequiredAPIKey {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(a.cfg.APIKeys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range a.cfg.APIKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) {
|
||||||
|
secret := []byte(strings.TrimSpace(token))
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
|
||||||
|
secret = decoded
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(secret)
|
||||||
|
key := fmt.Sprintf(a.cfg.SessionKeyPattern, hex.EncodeToString(digest[:]))
|
||||||
|
data, err := a.redis.Get(ctx, key).Bytes()
|
||||||
|
if err != nil && errors.Is(err, redis.Nil) {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var session Session
|
||||||
|
if err := json.Unmarshal(data, &session); err != nil {
|
||||||
|
return nil, ErrAuthRequired
|
||||||
|
}
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(req *http.Request) (string, error) {
|
||||||
|
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||||
|
token, ok := strings.CutPrefix(authz, "Bearer ")
|
||||||
|
if !ok || strings.TrimSpace(token) == "" {
|
||||||
|
return "", ErrAuthRequired
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromDevHeaders(req *http.Request) *Principal {
|
||||||
|
uid := parseInt64Header(req, "X-User-ID", 0)
|
||||||
|
if uid <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Principal{
|
||||||
|
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||||
|
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||||
|
UserID: uid,
|
||||||
|
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID")),
|
||||||
|
AuthLevel: "auth",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64Header(req *http.Request, key string, fallback int64) int64 {
|
||||||
|
value := strings.TrimSpace(req.Header.Get(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 != ""}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
)
|
||||||
@@ -0,0 +1,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")
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
)
|
||||||
@@ -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<?")
|
||||||
|
args = append(args, opts.Cursor)
|
||||||
|
}
|
||||||
|
query := `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 ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
|
||||||
|
args = append(args, opts.Limit+1)
|
||||||
|
var records []AttendanceRecord
|
||||||
|
if err := s.db.SelectContext(ctx, &records, query, args...); err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(records) > 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<?")
|
||||||
|
args = append(args, cursor)
|
||||||
|
}
|
||||||
|
query := `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 ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
|
||||||
|
args = append(args, limit+1)
|
||||||
|
var items []AttendanceAppeal
|
||||||
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(items) > 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")
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user