From 81e3cf1d0325027aff2dc672bdb1916537323de5 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sat, 10 Oct 2026 23:34:06 +0800 Subject: [PATCH] feat: migrate workbench services --- Dockerfile | 8 +- Makefile | 2 +- README.md | 174 +- cmd/workbench/main.go | 2 +- configs/workbench.local.json | 56 +- configs/workbench.test.json | 50 +- docs/IM工作台AI前后端对接文档.md | 1787 ----------------- docs/IM工作台AI后端概要设计文档.md | 1289 ------------ docs/IM工作台AI接入需求文档.md | 1302 ------------ docs/sql/IM-工作台AI建表SQL.sql | 230 --- docs/工作台与Manager内部配置运维说明.md | 442 ---- docs/工作台迁移对接总结文档.md | 351 ---- docs/注册登录对接文档.md | 101 + docs/移动端任务与账户平台前后端对接文档.md | 1169 +++++++++++ .../figure-01.png | Bin 0 -> 93156 bytes .../figure-02.png | Bin 0 -> 85739 bytes .../figure-03.png | Bin 0 -> 60185 bytes .../figure-04.png | Bin 0 -> 64451 bytes docs/移动端任务与账户平台需求文档.docx | Bin 0 -> 341901 bytes docs/移动端任务与账户平台需求文档.md | 368 ++++ docs/迁移说明.md | 56 - go.mod | 16 - go.sum | 56 - internal/accounts/profile_test.go | 123 ++ internal/accounts/service.go | 460 +++++ internal/accounts/service_test.go | 100 + internal/ai/provider.go | 197 -- internal/ai/service.go | 560 ------ internal/ai/service_test.go | 54 - internal/app/app.go | 77 +- internal/auth/auth.go | 184 +- internal/auth/auth_test.go | 27 + internal/config/config.go | 261 +-- internal/config/config_test.go | 97 +- internal/events/events.go | 180 -- internal/finance/errors.go | 19 + internal/finance/service.go | 737 +++++++ internal/finance/service_test.go | 128 ++ internal/httpapi/accounts.go | 144 ++ internal/httpapi/admin.go | 392 ---- internal/httpapi/ai.go | 1183 ----------- internal/httpapi/ai_export_oss.go | 254 --- internal/httpapi/ai_export_test.go | 55 - internal/httpapi/ai_worker.go | 366 ---- internal/httpapi/api.go | 1203 +---------- internal/httpapi/attendance_payload_test.go | 40 - internal/httpapi/errors.go | 9 - internal/httpapi/finance.go | 201 ++ internal/httpapi/internal_auth.go | 72 - internal/httpapi/mailbox_test.go | 51 - internal/httpapi/manual_products.go | 104 + internal/httpapi/payload.go | 547 ----- internal/httpapi/payload_userid_test.go | 171 -- internal/httpapi/portal.go | 139 ++ internal/httpapi/profile.go | 160 ++ internal/httpapi/response.go | 68 +- internal/httpapi/tasks.go | 231 +++ internal/httpapi/uid.go | 142 -- internal/httpapi/uid_test.go | 47 - internal/mall/errors.go | 10 + internal/mall/service.go | 149 ++ internal/mall/service_test.go | 61 + internal/managerconfig/client.go | 235 --- internal/managerconfig/client_test.go | 83 - internal/portal/errors.go | 8 + internal/portal/service.go | 249 +++ internal/portal/service_test.go | 128 ++ internal/store/accounts.go | 175 ++ internal/store/admin.go | 306 --- internal/store/ai.go | 603 ------ internal/store/core_mysql.go | 116 -- internal/store/core_mysql_test.go | 23 - internal/store/errors.go | 32 +- internal/store/manual_products.go | 78 + internal/store/meeting_note_file_test.go | 94 - internal/store/mysql.go | 1332 +----------- internal/store/portal.go | 153 ++ internal/store/profile.go | 161 ++ internal/store/tasks.go | 480 +++++ internal/store/tenant_init.go | 115 -- internal/store/types.go | 234 --- internal/store/withdrawals.go | 477 +++++ internal/tableparse/tableparse.go | 262 --- internal/tableparse/tableparse_test.go | 49 - internal/tasks/errors.go | 14 + internal/tasks/service.go | 553 +++++ internal/tasks/service_test.go | 117 ++ scripts/migrate_workbench_tables.sh | 152 -- 88 files changed, 7358 insertions(+), 15333 deletions(-) delete mode 100644 docs/IM工作台AI前后端对接文档.md delete mode 100644 docs/IM工作台AI后端概要设计文档.md delete mode 100644 docs/IM工作台AI接入需求文档.md delete mode 100644 docs/sql/IM-工作台AI建表SQL.sql delete mode 100644 docs/工作台与Manager内部配置运维说明.md delete mode 100644 docs/工作台迁移对接总结文档.md create mode 100644 docs/注册登录对接文档.md create mode 100644 docs/移动端任务与账户平台前后端对接文档.md create mode 100644 docs/移动端任务与账户平台需求文档.assets/figure-01.png create mode 100644 docs/移动端任务与账户平台需求文档.assets/figure-02.png create mode 100644 docs/移动端任务与账户平台需求文档.assets/figure-03.png create mode 100644 docs/移动端任务与账户平台需求文档.assets/figure-04.png create mode 100644 docs/移动端任务与账户平台需求文档.docx create mode 100644 docs/移动端任务与账户平台需求文档.md delete mode 100644 docs/迁移说明.md create mode 100644 internal/accounts/profile_test.go create mode 100644 internal/accounts/service.go create mode 100644 internal/accounts/service_test.go delete mode 100644 internal/ai/provider.go delete mode 100644 internal/ai/service.go delete mode 100644 internal/ai/service_test.go create mode 100644 internal/auth/auth_test.go delete mode 100644 internal/events/events.go create mode 100644 internal/finance/errors.go create mode 100644 internal/finance/service.go create mode 100644 internal/finance/service_test.go create mode 100644 internal/httpapi/accounts.go delete mode 100644 internal/httpapi/admin.go delete mode 100644 internal/httpapi/ai.go delete mode 100644 internal/httpapi/ai_export_oss.go delete mode 100644 internal/httpapi/ai_export_test.go delete mode 100644 internal/httpapi/ai_worker.go delete mode 100644 internal/httpapi/attendance_payload_test.go delete mode 100644 internal/httpapi/errors.go create mode 100644 internal/httpapi/finance.go delete mode 100644 internal/httpapi/internal_auth.go delete mode 100644 internal/httpapi/mailbox_test.go create mode 100644 internal/httpapi/manual_products.go delete mode 100644 internal/httpapi/payload.go delete mode 100644 internal/httpapi/payload_userid_test.go create mode 100644 internal/httpapi/portal.go create mode 100644 internal/httpapi/profile.go create mode 100644 internal/httpapi/tasks.go delete mode 100644 internal/httpapi/uid.go delete mode 100644 internal/httpapi/uid_test.go create mode 100644 internal/mall/errors.go create mode 100644 internal/mall/service.go create mode 100644 internal/mall/service_test.go delete mode 100644 internal/managerconfig/client.go delete mode 100644 internal/managerconfig/client_test.go create mode 100644 internal/portal/errors.go create mode 100644 internal/portal/service.go create mode 100644 internal/portal/service_test.go create mode 100644 internal/store/accounts.go delete mode 100644 internal/store/admin.go delete mode 100644 internal/store/ai.go delete mode 100644 internal/store/core_mysql.go delete mode 100644 internal/store/core_mysql_test.go create mode 100644 internal/store/manual_products.go delete mode 100644 internal/store/meeting_note_file_test.go create mode 100644 internal/store/portal.go create mode 100644 internal/store/profile.go create mode 100644 internal/store/tasks.go delete mode 100644 internal/store/tenant_init.go delete mode 100644 internal/store/types.go create mode 100644 internal/store/withdrawals.go delete mode 100644 internal/tableparse/tableparse.go delete mode 100644 internal/tableparse/tableparse_test.go create mode 100644 internal/tasks/errors.go create mode 100644 internal/tasks/service.go create mode 100644 internal/tasks/service_test.go delete mode 100755 scripts/migrate_workbench_tables.sh diff --git a/Dockerfile b/Dockerfile index ad5c652..825ecc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,13 +9,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench FROM alpine:3.22 WORKDIR /app -ENV IM_WORKBENCH_CONFIG=/app/configs/workbench.test.json -ENV IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin -ENV IM_WORKBENCH_INTERNAL_SECRET=hotgo-workbench-internal -ENV IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com -ENV IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench -ENV IM_CONFIG_API_SECRET= -ENV IM_INTERNAL_API_SECRET= +ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json COPY --from=builder /out/im_workbench /app/im_workbench COPY configs /app/configs EXPOSE 6061 diff --git a/Makefile b/Makefile index eb90e8f..73aa177 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,4 @@ test: go test ./... run: - IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench + WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench diff --git a/README.md b/README.md index 9d5d7cc..90f3d68 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,70 @@ -# im_workbench +# Workbench -工作台服务从 `im_server` 拆分后的独立 Go 工程。当前阶段只拆服务和接口边界,数据库暂不迁移,继续访问现有租户库中的工作台表。 +移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。 -## 路由 +## 当前接口 +- `POST /v1/auth/register` +- `POST /v1/auth/login` +- `POST /v1/auth/logout` +- `GET /v1/me` +- `PATCH /v1/me/avatar` +- `PUT /v1/me/password` +- `PUT /v1/me/trade-password` +- `GET /v1/home` +- `GET /v1/banners` +- `GET /v1/contents/{content_key}` +- `GET /v1/member-levels` +- `GET /v1/task-halls` +- `GET /v1/tasks/eligibility` +- `GET /v1/tasks/stats` +- `POST /v1/task-orders/claim` +- `GET /v1/task-orders` +- `GET /v1/task-orders/{order_id}` +- `POST /v1/task-orders/{order_id}/submissions` +- `GET /v1/manual-products` +- `GET /v1/manual-products/{product_id}` +- `GET /v1/payment-accounts` +- `POST /v1/payment-accounts` +- `POST /v1/withdrawal-orders/quote` +- `POST /v1/withdrawal-orders` +- `GET /v1/withdrawal-orders` +- `GET /v1/withdrawal-orders/{order_id}` - `GET /healthz` - `GET /metrics` -- `GET /v1/workbench` -- `/v1/workbench/organization/*` -- `/v1/workbench/attendance/*` -- `/v1/workbench/meetings*` -- `/v1/workbench/meeting-notes` -- `/v1/workbench/reports/daily*` -- `/v1/workbench/reports/monthly*` -- `/v1/workbench/executive-mailbox/*` -- `/internal/admin/workbench/*`,HotGo 后台内部管理接口,使用服务间 HMAC 鉴权 -## 本地运行 +注册使用 `hg_app_member` 作为账户主档,并在同一事务中创建 `hg_app_member_profile` 和 `hg_wallet_account` 记录。登录按 `mobile_hash` 查找会员,校验 bcrypt 密码后在 Redis 中创建独立会话。 + +## 启动配置 + +核心环境变量: ```bash -IM_WORKBENCH_CONFIG=configs/workbench.local.json \ -IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \ -go run ./cmd/workbench +WORKBENCH_CONFIG=configs/workbench.local.json +WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local' +WORKBENCH_REDIS_ADDR='127.0.0.1:6379' +WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>' +WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>' +WORKBENCH_API_KEYS='' ``` -默认监听 `:6061`,默认连接本地 `workbench` 库和本地 Redis。 +`WORKBENCH_DATA_ENCRYPTION_KEY` 用于生成 `mobile_cipher`,`WORKBENCH_LOOKUP_HMAC_KEY` 用于生成 `mobile_hash`。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致。 -开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带: +## 请求头 -```text -X-User-ID: 1 -X-Tenant-ID: 1 -X-Tenant-Code: default +```http +X-API-Key: +X-Request-ID: +X-Device-ID: ``` -正式链路使用: +默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 -```text -Authorization: Bearer {token} -X-MeetingIM-APIKey: {apikey} -X-Tenant-Code: default -X-IM-Schema: 1 -X-Request-ID: {uuid} -``` - -服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。 - -`usr...` 用户 ID 编解码优先从 IM Manager 内部配置接口读取 UID 加密 key,读不到时回退到 -`auth.uid_key` 或环境变量 `IM_WORKBENCH_UID_KEY`。测试环境需要配置: - -```text -IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com -IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench -IM_WORKBENCH_MANAGER_SECRET={IM_CONFIG_API_SECRET} -``` - -默认读取 `GET /internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key`。 -如果后台配置使用了其他 group/key,可通过 `IM_WORKBENCH_MANAGER_UID_KEY_GROUP` 和 -`IM_WORKBENCH_MANAGER_UID_KEY_KEYS` 调整。 - -HotGo 后台访问 `/internal/admin/workbench/*` 时需要配置同一组内部鉴权参数: - -```text -IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin -IM_WORKBENCH_INTERNAL_SECRET={shared-secret} -``` - -测试环境也可以复用已有内部服务密钥:HotGo 的 `IM_WORKBENCH_API_SECRET` 为空时会使用 -`IM_INTERNAL_API_SECRET`,`im_workbench` 的 `IM_WORKBENCH_INTERNAL_SECRET` 为空时也会使用 -`IM_INTERNAL_API_SECRET`。 - -HotGo 后台在测试集群中建议直连工作台服务内网地址: - -```text -IM_WORKBENCH_API_BASE_URL=http://im-workbench.workbench-system.svc.cluster.local:6061 -``` - -测试镜像默认使用 `hotgo-workbench-internal` 作为工作台内部签名密钥;正式环境建议通过 -`IM_WORKBENCH_API_SECRET` 和 `IM_WORKBENCH_INTERNAL_SECRET` 覆盖为真实密钥。 - -## Kafka 事件 - -工作台写操作统一投递到 `im.workbench.event`。本地配置默认关闭 Kafka: - -```bash -IM_WORKBENCH_KAFKA_ENABLED=true \ -IM_WORKBENCH_KAFKA_BROKERS=127.0.0.1:9092 \ -IM_WORKBENCH_CONFIG=configs/workbench.local.json \ -go run ./cmd/workbench -``` - -测试环境使用 `configs/workbench.test.json`,Kafka 为明文 PLAINTEXT 直连,不配置 SASL/TLS。 - -## AI / DeepSeek 本地配置 - -工作台 AI 首版接入 DeepSeek。默认配置已在 `configs/workbench.local.json` 中给出,实际调用前需要配置 API Key: - -```bash -DEEPSEEK_API_KEY=sk-xxx \ -IM_WORKBENCH_CONFIG=configs/workbench.local.json \ -go run ./cmd/workbench -``` - -可选参数: - -```bash -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_DEFAULT_MODEL=deepseek-flash -DEEPSEEK_COMPLEX_MODEL=deepseek-v4-pro -DEEPSEEK_TIMEOUT_MS=60000 -DEEPSEEK_MAX_RETRIES=2 -DEEPSEEK_TEMPERATURE=0.2 -DEEPSEEK_MAX_OUTPUT_TOKENS=4096 -``` - -事件体字段: - -```json -{ - "event_id": "wbe_xxx", - "trace_id": "request-id", - "tenant_id": 1, - "tenant_code": "default", - "actor_user_id": 42, - "module": "meeting", - "action": "meeting.created", - "resource_id": "mtg_xxx", - "target_user_ids": [42, 43], - "title": "会议通知", - "summary": "项目周会", - "occurred_at": "2026-09-13T14:00:00Z", - "data": {} -} -``` - -## 构建 +## 验证 ```bash make test make build ``` -## 数据库原则 - -服务启动不自动建库建表,不执行 `CREATE TABLE` / `ALTER TABLE`。所有表结构和种子数据继续通过 SQL 审核后人工执行。 +前后端完整对接契约见 [`docs/移动端任务与账户平台前后端对接文档.md`](docs/移动端任务与账户平台前后端对接文档.md),已实现的注册登录简版契约见 [`docs/注册登录对接文档.md`](docs/注册登录对接文档.md)。 diff --git a/cmd/workbench/main.go b/cmd/workbench/main.go index bc91282..4af2b4d 100644 --- a/cmd/workbench/main.go +++ b/cmd/workbench/main.go @@ -34,7 +34,7 @@ func main() { errCh := make(chan error, 1) go func() { - log.Printf("im_workbench listening on %s", cfg.Listen) + log.Printf("workbench listening on %s", cfg.Listen) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } diff --git a/configs/workbench.local.json b/configs/workbench.local.json index c294f1d..ca5f2a0 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -1,62 +1,22 @@ { "listen": ":6061", "mysql": { - "dsn": "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local", - "max_open_conns": 15, - "max_idle_conns": 5 - }, - "core_mysql": { - "dsn": "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local", + "dsn": "root:root@tcp(192.168.1.135:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local", "max_open_conns": 15, "max_idle_conns": 5 }, "redis": { "addr": "127.0.0.1:6379", - "db": 0, - "key_prefix": "im:" - }, - "kafka": { - "enabled": true, - "brokers": [ - "127.0.0.1:9092" - ], - "topic": "im.workbench.event", - "client_id": "im_workbench_local" + "db": 0 }, "auth": { "api_keys": [], - "session_key_pattern": "im:session:token:%s", - "uid_key": "dkXxWLwqUNRfYmdXRzOkZw==", + "session_key_pattern": "workbench:session:token:%s", + "session_ttl_seconds": 1209600, + "default_tenant_id": 0, + "data_encryption_key": "", + "lookup_hmac_key": "", "allow_dev_header_auth": true, - "required_tenant_code": true, - "required_api_key": false, - "skip_session_in_dev": false, - "internal_service_name": "hotgo-admin", - "internal_secret": "local-workbench-secret" - }, - "manager": { - "base_url": "", - "service_name": "im-workbench", - "secret": "", - "uid_key_group": "im", - "uid_key_keys": [ - "uid_encryption_key", - "UID_ENCRYPTION_KEY", - "uid_key" - ], - "timeout_ms": 3000 - }, - "ai": { - "provider": "deepseek", - "default_model": "deepseek-flash", - "complex_model": "deepseek-v4-pro", - "temperature": 0.2, - "max_output_tokens": 4096, - "deepseek": { - "base_url": "https://api.deepseek.com", - "api_key": "", - "timeout_ms": 60000, - "max_retries": 2 - } + "required_api_key": false } } diff --git a/configs/workbench.test.json b/configs/workbench.test.json index efd6a58..b7655d4 100644 --- a/configs/workbench.test.json +++ b/configs/workbench.test.json @@ -1,49 +1,21 @@ { "listen": ":6061", "mysql": { - "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local" - }, - "core_mysql": { - "dsn": "root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local" + "dsn": "", + "max_open_conns": 15, + "max_idle_conns": 5 }, "redis": { - "addr": "redis.118im.com:30637", - "password": "HMTJzkm.h>Y+E9ep", - "db": 0, - "key_prefix": "im:" - }, - "kafka": { - "enabled": true, - "brokers": [ - "kafka.118im.com:30835" - ], - "topic": "im.workbench.event", - "username": "", - "password": "", - "sasl": "", - "client_id": "im_workbench_test" + "addr": "", + "db": 0 }, "auth": { "api_keys": [], - "session_key_pattern": "im:session:token:%s", - "uid_key": "dkXxWLwqUNRfYmdXRzOkZw==", - "allow_dev_header_auth": false, - "required_tenant_code": true, - "required_api_key": true, - "skip_session_in_dev": false, - "internal_service_name": "hotgo-admin", - "internal_secret": "" - }, - "manager": { - "base_url": "https://admin.118im.com", - "service_name": "im-workbench", - "secret": "", - "uid_key_group": "im", - "uid_key_keys": [ - "uid_encryption_key", - "UID_ENCRYPTION_KEY", - "uid_key" - ], - "timeout_ms": 3000 + "session_key_pattern": "workbench:session:token:%s", + "session_ttl_seconds": 1209600, + "default_tenant_id": 0, + "data_encryption_key": "", + "lookup_hmac_key": "", + "required_api_key": true } } diff --git a/docs/IM工作台AI前后端对接文档.md b/docs/IM工作台AI前后端对接文档.md deleted file mode 100644 index 7e3d36a..0000000 --- a/docs/IM工作台AI前后端对接文档.md +++ /dev/null @@ -1,1787 +0,0 @@ -# IM 工作台 AI 前后端对接文档 - -> 版本:v1.0 -> 日期:2026-09-25 -> 需求依据:[IM 工作台 AI 接入需求文档](./IM工作台AI接入需求文档.md) -> IM 依据:[IM 前后端对接文档](./IM-前后端对接文档.md) -> 参考格式:[禾圃集后院养成与物友动态前后端对接文档](./禾圃集-后院养成与物友动态前后端对接文档.md) -> 说明:本文补充 IM 工作台 AI 能力的前后端对接契约,包括工作台 AI 入口、应用搜索、智能文档、智能表格、AI 美文、日报/月报 AI 接入、会议笔记 AI 总结、AI 任务与记录。IM 登录、租户、用户体系、文件服务、工作台原有日报/月报/会议接口沿用现有 IM 与 workbench 协议。 - -## 0. 公共对接约定 - -### 0.1 实现状态 - -| 状态 | 含义 | -| --- | --- | -| `新增` | 当前工作台接口未覆盖,需要新增服务端模型和接口 | -| `复用` | 沿用 IM 或 workbench 既有能力,例如登录、租户、文件上传、日报/月报提交 | -| `需改造` | 已有基础能力,但需要补充 AI 字段、记录、状态或权限判断 | -| `纯客户端` | 只影响本地 UI、动效、输入缓存或前端路由,不新增服务端接口 | - -本文所有接口均为目标契约。接口完成前,前端可按相同字段结构使用 Mock;联调时以本文字段名、枚举值、错误码和幂等规则为准。所有 ID、游标、用户 ID、会议 ID、文件 ID 统一按字符串处理。 - -### 0.2 鉴权、租户与路径 - -工作台 AI 接口复用 IM 登录态: - -1. 客户端必须先按 IM 登录流程拿到用户 Token,再访问本文接口。 -2. 服务端从 Token 与 Header 解析当前租户和用户,客户端不得在请求体中传 `tenant_id`、`user_id` 来覆盖身份。 -3. 工作台 AI 数据必须按租户、用户隔离;用户只能访问本人创建的 AI 任务和记录,会议 AI 总结按会议笔记权限判断。 -4. 写接口必须支持幂等,客户端传 `Idempotency-Key`;同一幂等键请求体不同,返回 `IDEMPOTENCY_CONFLICT`。 -5. AI 内容、原始输入、上传文件解析结果、模型响应和错误日志均需脱敏后入库或打印。 - -建议路径前缀: - -```text -https://{host}/v1/workbench/ai/* -``` - -公共 Header: - -| Header | 必填 | 说明 | -| --- | --- | --- | -| `Authorization` | 是 | `Bearer ` | -| `X-MeetingIM-APIKey` | 是 | 沿用 IM 客户端预置 API Key | -| `X-IM-Schema` | 是 | 固定传 `1` | -| `X-Tenant-Code` | 是 | 当前租户编码,例如 `system` | -| `X-Request-ID` | 是 | 客户端生成的请求追踪 ID | -| `Idempotency-Key` | 写接口必填 | 幂等键,避免弱网重复提交 | - -文件上传沿用 IM 文件服务。智能表格原始文件建议上传为 `purpose=ai_table_source`,会议笔记附件仍沿用会议笔记既有 `purpose`。 - -### 0.3 成功与失败响应 - -成功响应统一为: - -```json -{ - "code": 0, - "data": {}, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", - "server_time": "2026-09-25T08:30:15.123Z" -} -``` - -失败响应统一为: - -```json -{ - "code": 30000, - "reason": "MALFORMED_REQUEST", - "message": "malformed request", - "details": { - "field": "source_text", - "limit": 1000 - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", - "server_time": "2026-09-25T08:30:15.123Z" -} -``` - -前端只使用稳定 `reason` 分支和本地化文案,不解析 `message`。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。时间统一为 UTC RFC3339。 - -### 0.4 枚举约定 - -#### 0.4.1 AI 能力类型 - -| 枚举 | 说明 | -| --- | --- | -| `smart_document` | 智能文档 | -| `smart_table` | 智能表格/报告解读 | -| `text_polish` | AI 美文 | -| `daily_report_polish` | 日报 AI 美化 | -| `monthly_report_polish` | 月报 AI 美化 | -| `meeting_summary` | 会议笔记 AI 总结 | - -#### 0.4.2 AI 任务状态 - -| 枚举 | 说明 | -| --- | --- | -| `queued` | 已入队,等待执行 | -| `processing` | 处理中 | -| `succeeded` | 处理成功 | -| `failed` | 处理失败 | -| `canceled` | 已取消 | -| `expired` | 结果已过期 | - -#### 0.4.3 AI 记录类型 - -| 枚举 | 说明 | -| --- | --- | -| `smart_document` | 智能文档记录 | -| `smart_table` | 智能表格解读记录 | -| `text_polish` | AI 美文记录,首版可不展示到记录中心 | -| `meeting_summary` | 会议总结记录,挂在会议下 | - -#### 0.4.4 文件类型 - -| 枚举 | 说明 | -| --- | --- | -| `xlsx` | Excel 工作簿 | -| `xls` | 旧版 Excel 工作簿 | -| `csv` | CSV 数据文件 | -| `docx` | Word 文档,导出场景使用 | -| `pdf` | PDF 文档,导出场景使用 | - -#### 0.4.5 表格解读步骤 - -| 枚举 | 说明 | -| --- | --- | -| `read_file` | 读取表格数据 | -| `detect_metrics` | 识别核心指标 | -| `analyze_trends` | 分析趋势变化 | -| `generate_report` | 生成解读报告 | - -#### 0.4.6 AI 推送结果类型 - -| 枚举 | 说明 | -| --- | --- | -| `text` | 文本结果,适用于 AI 美文、日报/月报润色、较短的智能文档草稿 | -| `file` | 文件结果,适用于导出 docx、pdf 等文件;推送中返回文件服务下载地址 | -| `record` | 记录结果,适用于智能表格、智能文档等已生成记录的能力 | -| `summary` | 会议总结类结果,挂在会议下展示 | - -### 0.5 通用错误码 - -| reason | HTTP | 说明 | 前端处理 | -| --- | --- | --- | --- | -| `UNAUTHORIZED` | 401 | Token 缺失、过期或无效 | 回到登录 | -| `FORBIDDEN` | 403 | 无权限访问 AI 能力、记录或会议 | 展示无权限 | -| `MALFORMED_REQUEST` | 400 | 请求体格式错误 | 按接口提示修正 | -| `VALIDATION_FAILED` | 400 | 字段校验失败 | 按字段提示 | -| `RESOURCE_NOT_FOUND` | 404 | 对象不存在或无权感知 | 展示不存在或已失效 | -| `IDEMPOTENCY_CONFLICT` | 409 | 同一幂等键请求体不一致 | 停止重试并上报 | -| `AI_QUOTA_EXCEEDED` | 429 | AI 用量超限 | 提示今日次数已用完 | -| `RATE_LIMITED` | 429 | 触发频控 | 提示稍后再试 | -| `AI_PROVIDER_FAILED` | 502 | AI 供应商调用失败 | 可重试 | -| `AI_TIMEOUT` | 504 | AI 处理超时 | 展示超时并允许重试 | -| `AI_CONTENT_REJECTED` | 422 | 输入或输出命中内容安全 | 提示内容不适合生成 | -| `AI_TASK_FAILED` | 500 | AI 任务执行失败 | 展示失败并允许重试 | -| `FILE_TOO_LARGE` | 413 | 文件超过限制 | 提示压缩或更换文件 | -| `FILE_TYPE_UNSUPPORTED` | 415 | 文件类型不支持 | 提示仅支持 xls/xlsx/csv | -| `FILE_UNAVAILABLE` | 404 | 文件不存在、已过期或无权限 | 重新上传 | - -### 0.6 分页、轮询与幂等 - -| 场景 | 约定 | -| --- | --- | -| 列表分页 | `limit` 默认 20,最大 50;`cursor` 为服务端不透明字符串 | -| 任务轮询 | 前端创建任务后每 1.5 秒轮询一次,连续 30 秒后改为每 3 秒,最长 2 分钟 | -| 弱网重试 | 写接口使用同一个 `Idempotency-Key` 重试 | -| 取消任务 | 只允许取消 `queued`、`processing` 状态任务 | -| 记录保存 | 智能文档生成成功后,必须显式保存才进入文档记录;智能表格解读成功后默认生成记录 | -| 在线推送 | AI 任务完成后仅推送当前在线用户;离线用户不补推,上线后通过任务或记录接口重新拉取 | -| 推送兜底 | 前端接入推送后仍需保留轮询和页面恢复拉取,推送只作为实时体验优化 | - -## 1. 工作台 AI 入口与应用搜索 - -本章对应工作台首页、搜索默认页、搜索结果页和无结果页。工作台原有应用仍走既有接口;本文补充 AI 能力开关、限制和搜索字段。 - -### 1.1 获取工作台 AI 能力 - -#### 需求图片 - -工作台 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/capabilities` | -| 用途 | 获取当前租户、当前用户可用的 AI 能力、用量限制和入口展示状态 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `scene` | string | 否 | 调用场景,默认 `workbench_home` | - -#### 入参示例 - -```http -GET /v1/workbench/ai/capabilities?scene=workbench_home HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWB -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `enabled` | boolean | 工作台 AI 总开关 | -| `features[]` | array | 当前用户可用能力 | -| `features[].feature` | string | 能力类型,见 0.4.1 | -| `features[].enabled` | boolean | 是否可用 | -| `features[].entry_title` | string | 前端入口名称 | -| `features[].daily_limit` | number | 单用户每日可用次数,`0` 表示不限制 | -| `features[].remaining_today` | number | 今日剩余次数 | -| `features[].max_input_chars` | number | 输入最大字符数 | -| `features[].max_file_size_mb` | number | 文件大小限制,非文件能力为 `0` | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "enabled": true, - "features": [ - { - "feature": "smart_document", - "enabled": true, - "entry_title": "智能文档", - "daily_limit": 20, - "remaining_today": 18, - "max_input_chars": 1000, - "max_file_size_mb": 0 - }, - { - "feature": "smart_table", - "enabled": true, - "entry_title": "智能表格", - "daily_limit": 10, - "remaining_today": 9, - "max_input_chars": 100, - "max_file_size_mb": 20 - } - ] - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", - "server_time": "2026-09-25T08:30:15.123Z" -} -``` - -#### 对接重点 - -1. 首页“智能文档”“智能表格”是否展示,以 `features[].enabled` 为准。 -2. `remaining_today=0` 时入口可展示,但生成按钮置灰并提示次数已用完。 -3. 前端不要写死 AI 能力开关,方便后续按租户灰度。 - -### 1.2 获取工作台应用搜索默认页 - -#### 需求图片 - -工作台搜索页面 - -| 项 | 内容 | -| --- | --- | -| 状态 | `需改造` | -| 方法 | `GET /v1/workbench/apps` | -| 用途 | 获取搜索页默认展示的常用应用和全部应用,包含 AI 应用 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `include_ai` | boolean | 否 | 是否包含 AI 应用,默认 `true` | - -#### 入参示例 - -```http -GET /v1/workbench/apps?include_ai=true HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWC -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `common_apps[]` | array | 常用应用列表 | -| `all_apps[]` | array | 全部应用列表 | -| `app_id` | string | 应用 ID | -| `name` | string | 应用名称 | -| `icon` | string | 图标标识或图片 URL | -| `route` | string | 前端路由 | -| `type` | string | `builtin`、`ai` | -| `enabled` | boolean | 是否可点击 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "common_apps": [ - {"app_id": "daily_report", "name": "日报", "icon": "daily", "route": "/workbench/daily", "type": "builtin", "enabled": true}, - {"app_id": "smart_document", "name": "智能文档", "icon": "ai_document", "route": "/workbench/ai/document", "type": "ai", "enabled": true}, - {"app_id": "smart_table", "name": "智能表格", "icon": "ai_table", "route": "/workbench/ai/table", "type": "ai", "enabled": true} - ], - "all_apps": [ - {"app_id": "organization", "name": "组织框架", "icon": "org", "route": "/workbench/org", "type": "builtin", "enabled": true}, - {"app_id": "meeting_notes", "name": "会议笔记", "icon": "meeting_note", "route": "/workbench/meeting-notes", "type": "builtin", "enabled": true} - ] - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWC" -} -``` - -#### 对接重点 - -1. `route` 由前端路由解释,后端只返回稳定应用 ID 和显示名称。 -2. AI 应用是否出现在常用应用中,可由服务端按租户配置控制。 - -### 1.3 搜索工作台应用 - -#### 需求图片 - -工作台搜索结果 -工作台搜索无结果 - -| 项 | 内容 | -| --- | --- | -| 状态 | `需改造` | -| 方法 | `GET /v1/workbench/apps/search` | -| 用途 | 按关键字搜索工作台应用,返回可跳转应用结果 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `q` | string | 是 | 搜索关键词,最长 50 字符 | -| `include_ai` | boolean | 否 | 是否搜索 AI 应用,默认 `true` | - -#### 入参示例 - -```http -GET /v1/workbench/apps/search?q=%E7%BB%84%E7%BB%87&include_ai=true HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWD -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `items[]` | array | 匹配的应用 | -| `matched_text` | string | 命中的展示文本 | -| `highlight_ranges[]` | array | 高亮范围,可选 | -| `has_more` | boolean | 是否还有更多结果 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "items": [ - { - "app_id": "organization", - "name": "组织框架", - "matched_text": "组织框架", - "icon": "org", - "route": "/workbench/org", - "type": "builtin", - "enabled": true, - "highlight_ranges": [{"start": 0, "end": 2}] - } - ], - "has_more": false - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWD" -} -``` - -#### 对接重点 - -1. 无结果时返回 `items: []`,前端展示“暂无匹配结果”。 -2. 搜索只返回当前用户有权限访问的应用。 -3. 前端不要基于本地固定列表搜索,否则 AI 应用灰度会不一致。 - -## 2. 智能文档接口 - -本章对应智能文档输入页、生成结果页和文档记录页。智能文档是异步任务,生成成功后前端展示结果;用户点击“保存文档”后才进入文档记录。 - -### 2.1 创建智能文档生成任务 - -#### 需求图片 - -智能文档输入 -智能文档生成结果 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/documents/generate` | -| 用途 | 根据用户输入生成结构化文档 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `source_text` | string | 是 | 用户输入的核心内容,最长 1000 字 | -| `title_hint` | string | 否 | 标题提示,最长 80 字 | -| `document_type` | string | 否 | 文档类型,默认 `meeting_minutes`;可选 `meeting_minutes`、`plan`、`summary`、`general` | -| `language` | string | 否 | 语言,默认 `zh-CN` | - -#### 入参示例 - -```http -POST /v1/workbench/ai/documents/generate HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWE -Idempotency-Key: ai_doc_01K8ZC3FZ6BTX9Z1SK8HV7QYWE -Content-Type: application/json - -{ - "source_text": "今天讨论新项目排期,确认本周完成需求评审,下周启动开发,风险是接口字段还未冻结。", - "title_hint": "新项目沟通纪要", - "document_type": "meeting_minutes", - "language": "zh-CN" -} -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `task_id` | string | AI 任务 ID | -| `status` | string | 任务状态 | -| `polling_interval_ms` | number | 建议轮询间隔 | -| `expires_at` | string | 未保存结果过期时间 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_7R8mX5Nf4D2a", - "status": "queued", - "polling_interval_ms": 1500, - "expires_at": "2026-09-25T10:30:15.123Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWE" -} -``` - -#### 对接重点 - -1. 前端创建任务后跳转或停留在结果页 loading 状态,通过 2.2 查询任务。 -2. 输入为空、超过 1000 字或命中内容安全,必须返回明确错误码。 -3. 重新生成可再次调用本接口,并传新的幂等键。 - -### 2.2 查询 AI 任务详情 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/tasks/{task_id}` | -| 用途 | 查询智能文档、智能表格等异步任务进度和结果 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `task_id` | string | 是 | 路径参数 | - -#### 入参示例 - -```http -GET /v1/workbench/ai/tasks/aitask_7R8mX5Nf4D2a HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWF -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `task_id` | string | AI 任务 ID | -| `feature` | string | AI 能力类型 | -| `status` | string | 任务状态 | -| `progress` | number | 进度,0~100 | -| `steps[]` | array | 表格类任务步骤,智能文档可为空 | -| `result` | object | 任务结果,成功后返回 | -| `error` | object | 失败原因,失败后返回 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_7R8mX5Nf4D2a", - "feature": "smart_document", - "status": "succeeded", - "progress": 100, - "steps": [], - "result": { - "title": "新项目沟通纪要", - "content_markdown": "## 一、会议背景\n今天讨论新项目排期...\n\n## 二、关键结论\n- 本周完成需求评审\n- 下周启动开发", - "sections": [ - {"heading": "会议背景", "content": "今天讨论新项目排期。"}, - {"heading": "关键结论", "content": "本周完成需求评审,下周启动开发。"} - ], - "source_excerpt": "今天讨论新项目排期...", - "can_save": true - }, - "error": null - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWF" -} -``` - -#### 对接重点 - -1. `status=succeeded` 且 `result.can_save=true` 时前端展示“保存文档”。 -2. `content_markdown` 是主展示字段;`sections` 用于后续结构化编辑或导出。 -3. `status=failed` 时返回 `error.reason` 和 `error.message`,前端展示重试。 - -### 2.3 保存智能文档 - -#### 需求图片 - -智能文档编辑保存 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/documents` | -| 用途 | 将生成结果保存为文档记录 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `task_id` | string | 是 | 生成任务 ID | -| `title` | string | 是 | 保存标题,最长 100 字 | -| `content_markdown` | string | 是 | 保存内容 | -| `content_plain` | string | 否 | 纯文本内容,用于搜索 | - -#### 入参示例 - -```http -POST /v1/workbench/ai/documents HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWG -Idempotency-Key: ai_doc_save_01K8ZC3FZ6BTX9Z1SK8HV7QYWG -Content-Type: application/json - -{ - "task_id": "aitask_7R8mX5Nf4D2a", - "title": "新项目沟通纪要", - "content_markdown": "## 一、会议背景\n今天讨论新项目排期...", - "content_plain": "今天讨论新项目排期,本周完成需求评审。" -} -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `record_id` | string | 文档记录 ID | -| `title` | string | 文档标题 | -| `created_at` | string | 保存时间 | -| `updated_at` | string | 更新时间 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "record_id": "airec_doc_5Jv8mVb21Q", - "title": "新项目沟通纪要", - "created_at": "2026-09-25T08:35:00.000Z", - "updated_at": "2026-09-25T08:35:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWG" -} -``` - -#### 对接重点 - -1. 同一个 `task_id` 可以保存一次;重复保存同一幂等键返回同一 `record_id`。 -2. 前端编辑后的内容以保存接口入参为准,后端不再重新调用 AI。 -3. 未保存结果过期后保存返回 `RESOURCE_NOT_FOUND` 或 `AI_TASK_FAILED`。 - -### 2.4 文档记录列表 - -#### 需求图片 - -文档记录 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/documents` | -| 用途 | 查询当前用户保存的智能文档记录 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `query` | string | 否 | 标题或内容搜索 | -| `cursor` | string | 否 | 分页游标 | -| `limit` | number | 否 | 默认 20,最大 50 | - -#### 入参示例 - -```http -GET /v1/workbench/ai/documents?query=%E9%A1%B9%E7%9B%AE&cursor=&limit=20 HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWH -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `items[]` | array | 文档记录 | -| `record_id` | string | 记录 ID | -| `title` | string | 标题 | -| `summary` | string | 摘要 | -| `document_type` | string | 文档类型 | -| `status` | string | 固定 `succeeded` | -| `created_at` | string | 创建时间 | -| `updated_at` | string | 更新时间 | -| `next_cursor` | string | 下一页游标 | -| `has_more` | boolean | 是否还有更多 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "items": [ - { - "record_id": "airec_doc_5Jv8mVb21Q", - "title": "新项目沟通纪要", - "summary": "确认本周完成需求评审,下周启动开发。", - "document_type": "meeting_minutes", - "status": "succeeded", - "created_at": "2026-09-25T08:35:00.000Z", - "updated_at": "2026-09-25T08:35:00.000Z" - } - ], - "next_cursor": "", - "has_more": false - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWH" -} -``` - -#### 对接重点 - -1. 文档记录只返回当前用户保存的数据。 -2. 搜索建议同时匹配标题和 `content_plain`。 - -### 2.5 文档记录详情 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/documents/{record_id}` | -| 用途 | 获取智能文档保存内容 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参示例 - -```http -GET /v1/workbench/ai/documents/airec_doc_5Jv8mVb21Q HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWI -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "record_id": "airec_doc_5Jv8mVb21Q", - "title": "新项目沟通纪要", - "document_type": "meeting_minutes", - "content_markdown": "## 一、会议背景\n今天讨论新项目排期...", - "source_excerpt": "今天讨论新项目排期...", - "created_at": "2026-09-25T08:35:00.000Z", - "updated_at": "2026-09-25T08:35:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWI" -} -``` - -## 3. 智能表格与报告解读接口 - -本章对应智能表格首页、选择文件、上传成功、分析进度、报告解读和解读记录。文件上传先走 IM 文件服务,再创建表格解读任务。 - -### 3.1 上传智能表格源文件 - -#### 需求图片 - -智能表格 -选择文件 -文件上传成功 xlsx -文件上传成功 xls -文件上传成功 csv - -| 项 | 内容 | -| --- | --- | -| 状态 | `复用` | -| 方法 | `POST /v0/file/u/` | -| 用途 | 上传智能表格待分析文件,返回 `file_id` | -| 鉴权 | 沿用 IM 文件服务鉴权 | -| 幂等 | 文件服务按既有规则处理 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `id` | string | 是 | 客户端文件上传 ID | -| `purpose` | string | 是 | 固定 `ai_table_source` | -| `file` | file | 是 | 支持 `.xlsx`、`.xls`、`.csv` | - -#### 入参示例 - -```http -POST /v0/file/u/ HTTP/1.1 -X-MeetingIM-APIKey: -X-MeetingIM-Auth: token -X-IM-Schema: 1 -X-Tenant-Code: system -Content-Type: multipart/form-data - -id=file_01K8ZC3FZ6BTX9Z1SK8HV7QYWJ -purpose=ai_table_source -file=@sales_august.xlsx -``` - -#### 出参示例 - -```json -{ - "ctrl": { - "id": "file_01K8ZC3FZ6BTX9Z1SK8HV7QYWJ", - "code": 200, - "text": "ok", - "params": { - "file_id": "fil_8xMb92qK7L", - "name": "sales_august.xlsx", - "size": 2726297, - "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - } - } -} -``` - -#### 对接重点 - -1. 文件服务响应结构沿用 IM `ctrl`,不是 workbench 统一 `code/data` 包装。 -2. 前端需在本地先校验扩展名和大小,后端仍必须二次校验。 -3. `file_id` 只作为后续创建 AI 表格任务的输入,不直接暴露给其他用户。 - -### 3.2 创建表格解读任务 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/table-analyses` | -| 用途 | 根据上传文件和报表主题创建智能表格解读任务 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `file_id` | string | 是 | 文件服务返回的文件 ID | -| `file_name` | string | 是 | 原始文件名 | -| `file_type` | string | 是 | `xlsx`、`xls`、`csv` | -| `file_size` | number | 是 | 文件大小,单位 byte | -| `topic` | string | 是 | 报表主题,最长 100 字 | -| `sheet_name` | string | 否 | 指定分析工作表;为空时服务端自动选择 | -| `language` | string | 否 | 默认 `zh-CN` | - -#### 入参示例 - -```http -POST /v1/workbench/ai/table-analyses HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWK -Idempotency-Key: ai_table_01K8ZC3FZ6BTX9Z1SK8HV7QYWK -Content-Type: application/json - -{ - "file_id": "fil_8xMb92qK7L", - "file_name": "sales_august.xlsx", - "file_type": "xlsx", - "file_size": 2726297, - "topic": "8月销售数据", - "sheet_name": "", - "language": "zh-CN" -} -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `task_id` | string | AI 任务 ID | -| `record_id` | string | 预生成记录 ID;任务成功后可打开详情 | -| `status` | string | 任务状态 | -| `polling_interval_ms` | number | 建议轮询间隔 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_tbl_2Fh9aQx73K", - "record_id": "airec_tbl_6LP0nM9s3B", - "status": "queued", - "polling_interval_ms": 1500 - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWK" -} -``` - -#### 对接重点 - -1. 表格解读成功后默认生成记录,无需用户另点保存。 -2. 文件不存在、类型不支持、超过大小限制必须在创建任务时拦截。 -3. 同一用户可同时运行的表格任务建议限制为 1~3 个,超过返回 `RATE_LIMITED`。 - -### 3.3 查询表格解读进度 - -#### 需求图片 - -智能解读报告处理中 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/tasks/{task_id}` | -| 用途 | 查询表格解读任务进度 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_tbl_2Fh9aQx73K", - "feature": "smart_table", - "status": "processing", - "progress": 68, - "steps": [ - {"step": "read_file", "title": "读取表格数据", "status": "succeeded"}, - {"step": "detect_metrics", "title": "识别核心指标", "status": "succeeded"}, - {"step": "analyze_trends", "title": "分析趋势变化", "status": "processing"}, - {"step": "generate_report", "title": "生成解读报告", "status": "pending"} - ], - "result": null, - "error": null - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWL" -} -``` - -#### 对接重点 - -1. 前端进度条使用 `progress`,步骤列表使用 `steps`。 -2. 服务端不保证每次轮询都有进度变化,但状态必须单向推进。 -3. 任务成功后 `result.record_id` 指向报告详情。 - -### 3.4 取消表格解读任务 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/tasks/{task_id}/cancel` | -| 用途 | 取消正在排队或处理中的 AI 任务 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参示例 - -```http -POST /v1/workbench/ai/tasks/aitask_tbl_2Fh9aQx73K/cancel HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWM -Idempotency-Key: ai_cancel_01K8ZC3FZ6BTX9Z1SK8HV7QYWM -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_tbl_2Fh9aQx73K", - "status": "canceled", - "canceled_at": "2026-09-25T08:40:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWM" -} -``` - -### 3.5 获取报告解读详情 - -#### 需求图片 - -报告解读 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/table-analyses/{record_id}` | -| 用途 | 获取智能表格解读报告详情 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `record_id` | string | 报告记录 ID | -| `title` | string | 报告标题 | -| `file` | object | 源文件信息 | -| `metrics[]` | array | 核心指标 | -| `preview` | object | 数据预览 | -| `alerts[]` | array | 异常提醒 | -| `interpretation` | string | 核心解读 | -| `export_options[]` | array | 可导出格式 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "record_id": "airec_tbl_6LP0nM9s3B", - "title": "8月销售数据", - "file": { - "file_id": "fil_8xMb92qK7L", - "file_name": "sales_august.xlsx", - "file_type": "xlsx", - "file_size": 2726297 - }, - "metrics": [ - {"name": "销售额", "value": "128.6万", "change_text": "+12.3%", "trend": "up"}, - {"name": "订单量", "value": "3,842", "change_text": "+8.7%", "trend": "up"}, - {"name": "退款率", "value": "2.1%", "change_text": "-0.4%", "trend": "down"} - ], - "preview": { - "columns": ["日期", "销售额", "订单量", "退款率"], - "rows": [ - ["8/1", "42,300", "126", "2.3%"], - ["8/2", "48,120", "139", "2.0%"] - ], - "total_rows": 31 - }, - "alerts": [ - {"level": "warning", "title": "8月15日订单量异常下降", "description": "订单量较前一日下降 18%,建议核对促销和渠道数据。"} - ], - "interpretation": "8月整体销售额较上月增长,订单量同步提升,退款率下降,说明转化质量改善。", - "export_options": ["docx", "pdf"], - "created_at": "2026-09-25T08:42:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWN" -} -``` - -#### 对接重点 - -1. 核心指标、异常提醒、核心解读都由后端生成并持久化,前端只负责展示。 -2. `preview.rows` 只返回前 N 行用于展示,不返回完整表格数据。 -3. 前端展示“由 AI 生成,请核对后使用”的风险提示。 - -### 3.6 解读记录列表 - -#### 需求图片 - -解读记录 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/table-analyses` | -| 用途 | 查询当前用户的表格解读记录 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `query` | string | 否 | 按标题或文件名搜索 | -| `cursor` | string | 否 | 分页游标 | -| `limit` | number | 否 | 默认 20,最大 50 | - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `items[].record_id` | string | 解读记录 ID | -| `items[].title` | string | 解读报告标题 | -| `items[].summary` | string | 解读摘要,列表页可展示前几行 | -| `items[].format` | string | 展示用文件格式,取值为小写文件后缀,例如 `xlsx`、`xls`、`csv` | -| `items[].file_type` | string | 原始文件类型,当前与 `format` 一致,保留给前端逻辑判断 | -| `items[].file_name` | string | 原始文件名 | -| `items[].file_size` | number | 原始文件大小,单位 byte | -| `items[].status` | string | 解读状态,见 0.4.2 | -| `items[].created_at` | string | 创建时间,UTC RFC3339 | -| `items[].updated_at` | string | 更新时间,UTC RFC3339 | -| `next_cursor` | string | 下一页游标,为空表示无下一页 | -| `has_more` | boolean | 是否还有下一页 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "items": [ - { - "record_id": "airec_tbl_6LP0nM9s3B", - "title": "8月销售数据", - "summary": "本次报表展示 8 月销售数据的核心指标、异常波动和区域表现。", - "format": "xlsx", - "file_name": "sales_august.xlsx", - "file_type": "xlsx", - "file_size": 18146135, - "status": "succeeded", - "created_at": "2026-09-25T08:42:00.000Z", - "updated_at": "2026-09-25T08:42:00.000Z" - } - ], - "next_cursor": "", - "has_more": false - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWO" -} -``` - -### 3.7 导出解读报告 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/table-analyses/{record_id}/export` | -| 用途 | 将解读报告导出为文件 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `format` | string | 是 | `docx` 或 `pdf` | - -#### 入参示例 - -```http -POST /v1/workbench/ai/table-analyses/airec_tbl_6LP0nM9s3B/export HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWP -Idempotency-Key: ai_export_01K8ZC3FZ6BTX9Z1SK8HV7QYWP -Content-Type: application/json - -{"format": "docx"} -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "file_id": "fil_export_9pLm2Q", - "file_name": "8月销售数据-解读报告.docx", - "download_url": "https://api.118im.com/v0/file/s/fil_export_9pLm2Q", - "expires_at": "2026-09-26T08:42:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWP" -} -``` - -## 4. AI 美文与日报/月报接入 - -AI 美文是同步接口,用于优化短文案。日报、月报仍使用原提交接口,AI 只负责生成或优化输入框内容。 - -### 4.1 AI 美文 - -#### 需求图片 - -AI美文 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `POST /v1/workbench/ai/text/polish` | -| 用途 | 对原文进行润色,返回可直接使用的优化文案 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `source_text` | string | 是 | 原文,最长 300 字 | -| `scene` | string | 是 | `general`、`daily_report`、`monthly_report` | -| `tone` | string | 否 | 语气,默认 `professional` | -| `max_output_chars` | number | 否 | 输出最大长度,默认 300 | - -#### 入参示例 - -```http -POST /v1/workbench/ai/text/polish HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWQ -Idempotency-Key: ai_polish_01K8ZC3FZ6BTX9Z1SK8HV7QYWQ -Content-Type: application/json - -{ - "source_text": "今天完成接口联调,处理了几个bug,明天继续测试。", - "scene": "daily_report", - "tone": "professional", - "max_output_chars": 300 -} -``` - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `polished_text` | string | 润色后的文案 | -| `source_text` | string | 原文回显 | -| `usage` | object | 本次用量信息 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "source_text": "今天完成接口联调,处理了几个bug,明天继续测试。", - "polished_text": "今日已完成接口联调,并修复联调过程中发现的多个问题。明日将继续推进测试验证,确保相关功能稳定可用。", - "usage": { - "remaining_today": 17 - } - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWQ" -} -``` - -#### 对接重点 - -1. 前端“重新生成”再次调用本接口,使用新的幂等键。 -2. 前端“使用文案”只把 `polished_text` 回填到原业务输入框,不自动提交日报或月报。 -3. 输出仍允许用户编辑,业务提交以用户最终输入为准。 - -### 4.2 日报 AI 接入 - -#### 需求图片 - -日报 AI 接入 - -| 项 | 内容 | -| --- | --- | -| 状态 | `复用 + 需改造` | -| AI 接口 | `POST /v1/workbench/ai/text/polish` | -| 业务接口 | 沿用日报新增/编辑接口 | -| 用途 | 在日报“今日工作总结”等输入框中接入 AI 美文 | -| 鉴权 | 需要 IM 登录态 | - -#### 对接流程 - -1. 用户在日报输入框填写原文。 -2. 前端点击“AI 美文”,调用 4.1,`scene=daily_report`。 -3. 前端展示 AI 美文弹层,用户可重新生成或使用文案。 -4. 用户点击“使用文案”后,前端把 `polished_text` 回填到日报字段。 -5. 用户点击“提交”后,仍调用原日报提交接口。 - -#### 入参示例 - -```json -{ - "source_text": "完成工作台接口联调,修复会议笔记字段问题。", - "scene": "daily_report", - "tone": "professional", - "max_output_chars": 300 -} -``` - -#### 对接重点 - -1. AI 不改变日报是否可提交、是否冲突、是否重复的业务规则。 -2. 日报保存成功后不需要额外保存 AI 记录;如后端需要审计,可写入用量日志。 - -### 4.3 月报 AI 接入 - -#### 需求图片 - -月报 AI 接入 - -| 项 | 内容 | -| --- | --- | -| 状态 | `复用 + 需改造` | -| AI 接口 | `POST /v1/workbench/ai/text/polish` | -| 业务接口 | 沿用月报新增/编辑接口 | -| 用途 | 在月报“本月工作总结”“其他事项”等输入框中接入 AI 美文 | -| 鉴权 | 需要 IM 登录态 | - -#### 对接流程 - -1. 用户在月报输入框填写原文。 -2. 前端点击对应输入框附近的“AI 美文”。 -3. 前端调用 4.1,`scene=monthly_report`。 -4. 用户确认使用后,前端只回填当前字段。 -5. 月报发布仍调用原月报接口。 - -#### 对接重点 - -1. 月报字段上限以月报业务接口为准;AI 输入建议最长 1000 字,输出不超过原字段限制。 -2. “发布中”状态由原月报接口控制,AI 美文接口不返回发布状态。 - -## 5. 会议笔记 AI 总结接口 - -会议 AI 总结挂在会议笔记详情下,使用会议附件、会议标题、参与人等已有会议上下文生成摘要。会议列表、会议笔记列表和文件上传仍沿用既有工作台会议接口。 - -### 5.1 创建会议 AI 总结任务 - -#### 需求图片 - -会议笔记 AI 总结 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增 + 需改造` | -| 方法 | `POST /v1/workbench/meetings/{meeting_id}/ai-summary` | -| 用途 | 对会议笔记附件或会议内容生成 AI 总结 | -| 鉴权 | 需要 IM 登录态和会议笔记查看权限 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `meeting_id` | string | 是 | 路径参数 | -| `file_ids[]` | array | 否 | 指定参与总结的会议附件;为空时使用会议下可读附件 | -| `summary_type` | string | 否 | 默认 `standard` | -| `force_regenerate` | boolean | 否 | 已存在总结时是否重新生成,默认 `false` | - -#### 入参示例 - -```http -POST /v1/workbench/meetings/mtg_7YUHoLTqrl4ZO_G6/ai-summary HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWR -Idempotency-Key: ai_meeting_01K8ZC3FZ6BTX9Z1SK8HV7QYWR -Content-Type: application/json - -{ - "file_ids": ["fil_note_1", "fil_note_2"], - "summary_type": "standard", - "force_regenerate": false -} -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_mtg_3Rn8qU2b6X", - "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", - "status": "queued", - "polling_interval_ms": 1500 - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWR" -} -``` - -#### 对接重点 - -1. 无会议查看权限返回 `FORBIDDEN`。 -2. 会议没有可分析内容时返回 `VALIDATION_FAILED`,details 中标明 `missing_source=true`。 -3. 已有总结且 `force_regenerate=false` 时可直接返回现有总结或返回 `CONFLICT`,建议返回现有总结以减少前端分支。 - -### 5.2 查询会议 AI 总结 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/meetings/{meeting_id}/ai-summary` | -| 用途 | 获取会议 AI 总结内容 | -| 鉴权 | 需要 IM 登录态和会议笔记查看权限 | -| 幂等 | 读接口无需幂等 | - -#### 出参说明 - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `meeting_id` | string | 会议 ID | -| `summary_id` | string | 总结 ID | -| `status` | string | `empty`、`processing`、`succeeded`、`failed` | -| `conclusions[]` | array | 会议结论 | -| `decisions[]` | array | 关键决策 | -| `action_items[]` | array | 后续安排 | -| `editable` | boolean | 当前用户是否可编辑 | -| `updated_at` | string | 更新时间 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", - "summary_id": "aisum_7Kp2N8aV", - "status": "succeeded", - "conclusions": [ - "工作台 AI 首版接入智能文档、智能表格和 AI 美文。", - "日报、月报沿用原业务提交接口。" - ], - "decisions": [ - "表格解读采用异步任务,成功后默认生成记录。", - "会议 AI 总结挂载在会议笔记详情页。" - ], - "action_items": [ - { - "owner_user_id": "usrABAAnqj42BU", - "owner_display_name": "张三", - "content": "补充 AI 任务表结构设计", - "due_at": "2026-09-26T10:00:00.000Z", - "status": "pending" - } - ], - "editable": true, - "updated_at": "2026-09-25T08:50:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWS" -} -``` - -#### 对接重点 - -1. `status=empty` 时前端展示空态和“生成 AI 总结”入口。 -2. `status=processing` 时前端可复用任务轮询,也可定时刷新本接口。 -3. `action_items.owner_user_id` 必须是字符串,不允许返回数字。 - -### 5.3 编辑会议 AI 总结 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `PUT /v1/workbench/meetings/{meeting_id}/ai-summary` | -| 用途 | 保存用户手动编辑后的 AI 总结 | -| 鉴权 | 需要会议笔记编辑权限 | -| 幂等 | 必须携带 `Idempotency-Key` | - -#### 入参示例 - -```http -PUT /v1/workbench/meetings/mtg_7YUHoLTqrl4ZO_G6/ai-summary HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWT -Idempotency-Key: ai_meeting_save_01K8ZC3FZ6BTX9Z1SK8HV7QYWT -Content-Type: application/json - -{ - "summary_id": "aisum_7Kp2N8aV", - "conclusions": ["工作台 AI 首版接入三类能力。"], - "decisions": ["会议 AI 总结挂载在会议笔记详情页。"], - "action_items": [ - { - "owner_user_id": "usrABAAnqj42BU", - "content": "补充 AI 任务表结构设计", - "due_at": "2026-09-26T10:00:00.000Z", - "status": "pending" - } - ] -} -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", - "summary_id": "aisum_7Kp2N8aV", - "updated_at": "2026-09-25T09:00:00.000Z" - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWT" -} -``` - -#### 对接重点 - -1. 编辑保存后内容不再标记为纯 AI 原文,前端仍可展示“由 AI 自动生成,如有不准确可手动编辑”。 -2. 会议总结编辑权限建议复用会议笔记 `can_replace` 或 `can_upload` 权限,最终以后端权限判断为准。 - -## 6. AI 记录与任务通用接口 - -### 6.1 通用 AI 记录列表 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `GET /v1/workbench/ai/records` | -| 用途 | 按类型查询 AI 记录,供后续统一记录中心使用 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 读接口无需幂等 | - -#### 入参说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `type` | string | 否 | `smart_document`、`smart_table` | -| `query` | string | 否 | 搜索词 | -| `cursor` | string | 否 | 分页游标 | -| `limit` | number | 否 | 默认 20,最大 50 | - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "items": [ - { - "record_id": "airec_doc_5Jv8mVb21Q", - "type": "smart_document", - "title": "新项目沟通纪要", - "summary": "确认本周完成需求评审,下周启动开发。", - "status": "succeeded", - "created_at": "2026-09-25T08:35:00.000Z", - "route": "/workbench/ai/document/airec_doc_5Jv8mVb21Q" - }, - { - "record_id": "airec_tbl_6LP0nM9s3B", - "type": "smart_table", - "title": "8月销售数据", - "summary": "销售额增长 12.3%,退款率下降。", - "status": "succeeded", - "created_at": "2026-09-25T08:42:00.000Z", - "route": "/workbench/ai/table/airec_tbl_6LP0nM9s3B" - } - ], - "next_cursor": "", - "has_more": false - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWU" -} -``` - -### 6.2 删除 AI 记录 - -| 项 | 内容 | -| --- | --- | -| 状态 | `新增` | -| 方法 | `DELETE /v1/workbench/ai/records/{record_id}` | -| 用途 | 删除当前用户自己的 AI 记录 | -| 鉴权 | 需要 IM 登录态 | -| 幂等 | 删除接口幂等,同一记录重复删除返回成功 | - -#### 入参示例 - -```http -DELETE /v1/workbench/ai/records/airec_doc_5Jv8mVb21Q HTTP/1.1 -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: system -X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWV -``` - -#### 出参示例 - -```json -{ - "code": 0, - "data": { - "record_id": "airec_doc_5Jv8mVb21Q", - "deleted": true - }, - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWV" -} -``` - -### 6.3 AI 任务事件 - -| 项 | 内容 | -| --- | --- | -| 状态 | `建议接入` | -| 事件 | `workbench.ai.task.updated` | -| 用途 | 服务端主动推送 AI 任务完成、失败或取消状态,减少前台等待 | -| 鉴权 | 复用 IM WebSocket 登录态 | - -#### 推送边界 - -1. 仅推送在线用户。用户离线时服务端不保存离线 AI 推送、不补发历史推送。 -2. 用户重新上线、重新进入工作台 AI 页面时,前端通过任务详情、记录列表或业务详情接口恢复状态。 -3. 推送内容只放任务状态、结果索引和轻量结果;完整长文本、大对象和权限敏感内容仍以 HTTP 详情接口为准。 -4. 文件结果不推裸 OSS 签名地址,统一推 IM 文件服务稳定地址:`/v0/file/s/{file_id}` 或其绝对 URL。 -5. 推送丢失、WebSocket 断线、App 切后台等场景下,前端必须保留轮询兜底。 - -#### 通用事件结构 - -```json -{ - "topic": "workbench.ai.task.updated", - "data": { - "task_id": "aitask_tbl_2Fh9aQx73K", - "feature": "smart_table", - "status": "succeeded", - "progress": 100, - "result_type": "record", - "record_id": "airec_tbl_6LP0nM9s3B", - "result": { - "title": "8月销售数据解读", - "summary": "销售额整体平稳,建议重点关注渠道结构和异常订单。" - }, - "updated_at": "2026-09-25T08:42:00.000Z" - } -} -``` - -#### 字段说明 - -| 字段 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `task_id` | string | 是 | AI 任务 ID | -| `feature` | string | 是 | AI 能力类型,见 0.4.1 | -| `status` | string | 是 | `succeeded`、`failed`、`canceled` 为主要推送状态 | -| `progress` | number | 否 | 进度,完成态一般为 `100` | -| `result_type` | string | 否 | `text`、`file`、`record`、`summary` | -| `record_id` | string | 否 | 记录类结果 ID | -| `meeting_id` | string | 否 | 会议总结所属会议 ID | -| `summary_id` | string | 否 | 会议总结 ID | -| `result` | object | 否 | 轻量结果体,按 result_type 区分 | -| `error` | object | 否 | 失败信息,包含 `reason`、`message` | -| `updated_at` | string | 是 | 更新时间,UTC RFC3339 | - -#### result 示例 - -文本结果: - -```json -{ - "result_type": "text", - "result": { - "text": "润色后的文本内容", - "scene": "daily_report_polish" - } -} -``` - -文件结果: - -```json -{ - "result_type": "file", - "result": { - "file_id": "IHC4k7rlpo0.docx", - "file_name": "阿德勒-解读报告.docx", - "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "file_size": 2870, - "download_url": "https://api.118im.com/v0/file/s/IHC4k7rlpo0.docx" - } -} -``` - -记录结果: - -```json -{ - "result_type": "record", - "record_id": "airec_tbl_6LP0nM9s3B", - "result": { - "title": "8月销售数据解读", - "summary": "销售额整体平稳,建议关注渠道结构。" - } -} -``` - -会议总结结果: - -```json -{ - "result_type": "summary", - "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", - "summary_id": "aisum_3Rn8qU2b6X", - "result": { - "conclusion_count": 3, - "action_item_count": 2 - } -} -``` - -失败结果: - -```json -{ - "task_id": "aitask_tbl_2Fh9aQx73K", - "feature": "smart_table", - "status": "failed", - "progress": 100, - "error": { - "reason": "AI_TASK_FAILED", - "message": "生成失败,请重试" - }, - "updated_at": "2026-09-25T08:42:00.000Z" -} -``` - -#### 各接口推送建议 - -| 接口/能力 | 是否异步 | 是否推送 | result_type | 推送内容建议 | -| --- | --- | --- | --- | --- | -| `POST /v1/workbench/ai/documents/generate` 智能文档生成 | 是 | 是 | `text` 或 `record` | 内容较短可直接推 `title`、`content_plain`、`content_markdown`;内容较长只推 `task_id` 和摘要,前端拉任务详情 | -| `POST /v1/workbench/ai/documents` 保存文档 | 否 | 否 | - | 用户主动保存,HTTP 返回即可 | -| `POST /v1/workbench/ai/table-analyses` 智能表格解读 | 是 | 是 | `record` | 推 `record_id`、标题、摘要;前端可直接展示完成卡片,点开时拉详情 | -| `POST /v1/workbench/ai/table-analyses/{record_id}/export` 导出报告 | 可异步 | 是 | `file` | 推 `file_id`、`file_name`、`mime_type`、`file_size`、`download_url` | -| `POST /v1/workbench/ai/text/polish` AI 美文 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推润色后的 `text`,内容过长时只推摘要并拉任务详情 | -| 日报 AI 润色 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推润色后的日报内容或草稿 ID | -| 月报 AI 润色 | 可同步;超过 2 秒建议异步 | 异步时推 | `text` | 推 `work_summary`、`next_plan` 或草稿 ID | -| `POST /v1/workbench/meetings/{meeting_id}/ai-summary` 会议 AI 总结 | 是 | 是 | `summary` | 推 `meeting_id`、`summary_id`、结论数、待办数;完整内容拉详情 | -| `GET /v1/workbench/ai/tasks/{task_id}` 查询任务 | 否 | 否 | - | 前端主动拉取,作为推送兜底 | -| `POST /v1/workbench/ai/tasks/{task_id}/cancel` 取消任务 | 否 | 可选 | - | 多端在线时可推 `status=canceled`;单端场景可只依赖 HTTP 返回 | - -#### 对接重点 - -1. 推送用于“在线即时更新”,不是最终数据源;最终状态以 HTTP 查询为准。 -2. 前端收到 `succeeded` 后,可优先使用推送中的轻量结果更新 UI;需要完整内容时再拉详情。 -3. 前端收到 `file` 类型结果后,可直接展示下载按钮,下载地址使用 `download_url`。 -4. 前端收到 `failed` 后展示失败并允许重试,不展示供应商原始错误。 -5. App 启动、WebSocket 重连、进入 AI 页面时,前端重新拉任务或记录列表恢复离线期间完成的任务。 - -## 7. 对接重难点 - -### 7.1 IM 登录与 workbench AI 边界 - -AI 接口只接受 IM 登录态,不单独做 AI 登录。租户、用户、部门权限全部由 IM Token 和工作台权限体系派生。前端不要在请求体中传用户身份字段。 - -### 7.2 异步任务与用户体验 - -智能文档、智能表格、会议 AI 总结都可能耗时。前端必须处理 `queued`、`processing`、`succeeded`、`failed`、`canceled`,并在用户离开页面后可通过任务、记录或业务详情恢复结果。 - -推荐交互流程: - -1. 创建任务接口立即返回 `task_id`、`status=queued`、`polling_interval_ms`,前端进入 loading 或后台任务状态。 -2. 用户在线时,服务端通过 IM WebSocket 推送 `workbench.ai.task.updated`,前端收到完成事件后直接刷新 UI。 -3. 用户离线、App 切后台、WebSocket 断线时,服务端不补发离线 AI 推送;前端重新上线后主动拉任务详情、AI 记录列表或业务详情。 -4. 推送结果为 `text` 时,短文本可直接展示;长文本以 HTTP 任务详情为准。 -5. 推送结果为 `file` 时,前端直接使用 `download_url` 展示下载入口;该地址应为 IM 文件服务稳定地址,不是裸 OSS 签名地址。 -6. 推送结果为 `record` 或 `summary` 时,前端可先展示标题和摘要,点开详情时再请求对应详情接口。 -7. 同一账号多端在线时,所有在线端都可以收到任务完成推送;各端仍需按页面状态决定是否自动展示或只更新角标。 -8. 失败、取消也应更新本地任务状态;失败文案按 `error.reason` 做本地化,不展示供应商原始报错。 - -### 7.3 文件服务与 AI 服务衔接 - -智能表格先上传文件,再创建解读任务。后端创建任务时必须校验 `file_id` 属于当前租户和当前用户,并检查文件类型、大小、有效期。AI 服务不得直接信任前端传入的 `file_name`、`file_type`。 - -### 7.4 字段类型一致性 - -所有用户 ID、会议 ID、记录 ID、任务 ID、文件 ID 都是字符串。尤其是 `owner_user_id`、`attendee_user_ids`、`recorder_user_id`、`participant.user_id` 不允许使用 number。 - -### 7.5 内容安全与合规 - -AI 输入和输出都需要内容安全检查。命中安全规则时返回 `AI_CONTENT_REJECTED`;前端展示温和提示,不展示供应商原始错误。日志中不得打印完整用户输入、完整模型输出、Token、文件下载地址和内部密钥。 - -### 7.6 用量限制与灰度开关 - -AI 能力需要按租户和用户配置开关、每日次数、并发数和文件大小。前端展示入口前先读取 1.1;后端接口仍必须强校验,不能只依赖前端隐藏入口。 - -### 7.7 会议 AI 总结权限 - -会议 AI 总结不能只判断会议 ID 存在。生成、查看、编辑必须分别判断会议笔记权限;附件参与总结前也要校验附件属于该会议且当前用户可读。 - -## 8. 接口清单 - -| 模块 | 接口 | 方法 | 状态 | 说明 | -| --- | --- | --- | --- | --- | -| AI 能力 | `/v1/workbench/ai/capabilities` | GET | 新增 | 获取 AI 能力开关和用量 | -| 工作台应用 | `/v1/workbench/apps` | GET | 需改造 | 默认应用列表包含 AI 应用 | -| 工作台搜索 | `/v1/workbench/apps/search` | GET | 需改造 | 搜索工作台应用 | -| 智能文档 | `/v1/workbench/ai/documents/generate` | POST | 新增 | 创建文档生成任务 | -| AI 任务 | `/v1/workbench/ai/tasks/{task_id}` | GET | 新增 | 查询任务进度与结果 | -| 智能文档 | `/v1/workbench/ai/documents` | POST | 新增 | 保存生成文档 | -| 智能文档 | `/v1/workbench/ai/documents` | GET | 新增 | 文档记录列表 | -| 智能文档 | `/v1/workbench/ai/documents/{record_id}` | GET | 新增 | 文档记录详情 | -| 文件上传 | `/v0/file/u/` | POST | 复用 | 上传表格源文件 | -| 智能表格 | `/v1/workbench/ai/table-analyses` | POST | 新增 | 创建表格解读任务 | -| AI 任务 | `/v1/workbench/ai/tasks/{task_id}/cancel` | POST | 新增 | 取消 AI 任务 | -| 智能表格 | `/v1/workbench/ai/table-analyses/{record_id}` | GET | 新增 | 报告解读详情 | -| 智能表格 | `/v1/workbench/ai/table-analyses` | GET | 新增 | 解读记录列表 | -| 智能表格 | `/v1/workbench/ai/table-analyses/{record_id}/export` | POST | 新增 | 导出解读报告 | -| AI 美文 | `/v1/workbench/ai/text/polish` | POST | 新增 | 文案润色 | -| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | POST | 新增 | 创建会议 AI 总结 | -| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | GET | 新增 | 查询会议 AI 总结 | -| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | PUT | 新增 | 编辑会议 AI 总结 | -| AI 记录 | `/v1/workbench/ai/records` | GET | 新增 | 通用记录列表 | -| AI 记录 | `/v1/workbench/ai/records/{record_id}` | DELETE | 新增 | 删除 AI 记录 | - -## 9. 前后端联调建议 - -1. 先联调 `capabilities`、应用列表和搜索,确保入口展示一致。 -2. 再联调 AI 美文,因为它是同步接口,最适合验证鉴权、租户、用量和错误包。 -3. 智能文档联调重点是任务轮询、保存文档、文档记录。 -4. 智能表格联调重点是文件上传、文件归属校验、任务步骤、报告详情和导出。 -5. 会议 AI 总结最后联调,重点验证会议权限、附件权限和编辑保存。 -6. 每个写接口都要测试同一 `Idempotency-Key` 重试、不同请求体冲突、Token 过期、跨租户访问、无权限访问。 diff --git a/docs/IM工作台AI后端概要设计文档.md b/docs/IM工作台AI后端概要设计文档.md deleted file mode 100644 index 8e7f73d..0000000 --- a/docs/IM工作台AI后端概要设计文档.md +++ /dev/null @@ -1,1289 +0,0 @@ -# IM 工作台 AI 后端概要设计文档 - -> 版本:V1.0 -> 日期:2026-09-25 -> 关联文档:[IM 工作台 AI 接入需求文档](./IM工作台AI接入需求文档.md) · [IM 工作台 AI 前后端对接文档](./IM工作台AI前后端对接文档.md) · [IM 前后端对接文档](./IM-前后端对接文档.md) -> DeepSeek 依据:[DeepSeek 首次调用 API](https://api-docs.deepseek.com/zh-cn/) · [DeepSeek Chat Completions API](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion/) -> 说明:本文描述 IM 工作台 AI 能力的后端概要设计,覆盖智能文档、智能表格、AI 美文、日报/月报 AI 接入、会议笔记 AI 总结、AI 任务、AI 记录、DeepSeek 接入、配置、异步处理、数据表、安全和发布回滚。 - ---- - -## 一、设计目标 - -IM 工作台在现有日报、月报、会议、文件和组织能力基础上新增 AI 能力。后端需要在不破坏原 workbench 业务接口的前提下,提供统一的 AI 能力接入层、任务调度层、记录存储层和 DeepSeek Provider 适配层。 - -后端需要完成以下核心能力: - -1. **工作台 AI 入口**:按租户、用户、功能开关返回可用 AI 能力、用量限制和入口配置。 -2. **智能文档**:根据用户输入生成结构化文档,支持重新生成、编辑保存、记录查询。 -3. **智能表格**:接收已上传表格文件,解析数据、识别核心指标、生成报告解读,支持记录查询和导出。 -4. **AI 美文**:为普通文案、日报、月报提供同步润色能力,只回填业务表单,不替代原业务提交。 -5. **会议 AI 总结**:基于会议笔记附件和会议上下文生成结论、决策、后续安排,支持编辑保存。 -6. **DeepSeek 接入**:通过 DeepSeek OpenAI 兼容 Chat Completions API 接入模型,统一处理 prompt、超时、重试、错误映射、审计、脱敏和用量统计。 -7. **可靠性**:支持幂等、异步任务、失败重试、并发限制、租户隔离、内容安全和灰度开关。 - -### 设计原则 - -- AI 能力放在 `im_workbench` 服务内建设,登录、租户、用户、组织、文件、会议仍复用 IM 与 workbench 既有能力。 -- 所有 AI 业务表统一使用 `wb_ai_` 前缀。 -- 所有业务数据必须带 `tenant_id`、`tenant_code`、`user_id`;用户身份由 IM Token 解析,客户端不得传入并覆盖身份。 -- 所有对外 ID 统一按字符串返回,包括 `task_id`、`record_id`、`meeting_id`、`file_id`、`user_id`。 -- AI Provider 必须抽象为接口,首版实现 DeepSeek,后续可切换到其他模型供应商。 -- DeepSeek API Key、base_url、model、超时、并发、用量限制全部配置化,不写死在代码里。 -- AI 输入、输出、文件解析内容、prompt 和模型响应都可能包含敏感信息,日志只记录摘要、hash、长度、token 用量和错误码。 -- 同步接口只用于短文本 AI 美文;文档、表格、会议总结统一按异步任务处理。 -- AI 输出默认不作为最终事实,前端必须展示“由 AI 生成,请核对后使用”。 - ---- - -## 二、系统架构 - -### 2.1 服务边界 - -首版建议直接在 `im_workbench` 中新增 AI 模块,避免多服务联调成本。模块内部按 Provider、任务、记录、业务适配做清晰边界,后续可平滑拆成独立 AI Worker 服务。 - -```text -移动端 / Web - | - v -IM Gateway / Workbench API - | - +--------------------> IM Auth / Tenant - | Token、租户、用户上下文 - | - v -im_workbench - ├─ Workbench API AI HTTP 路由、参数校验、响应转换 - ├─ AI Application 智能文档、智能表格、AI 美文、会议总结用例 - ├─ AI Task Domain 任务状态机、幂等、进度、取消、重试 - ├─ AI Record Domain 文档记录、表格记录、会议总结记录 - ├─ AI Provider Adapter DeepSeek 适配、Prompt 编排、错误映射 - ├─ File Adapter IM 文件下载、表格解析、导出文件上传 - ├─ Meeting Adapter 会议、会议笔记、权限和附件 - ├─ Worker Kafka/Redis/MySQL 任务消费 - └─ MySQL / Redis / Kafka - | - +---------------> DeepSeek API - | Chat Completions - +---------------> IM File Service - | 文件上传、下载、导出 - +---------------> IM User/Org - 用户、租户、权限、组织信息 -``` - -### 2.2 推荐代码分层 - -```text -workbench/ai/ -├── api/ HTTP Handler、DTO、参数校验 -├── application/ -│ ├── capability/ AI 能力开关和配额 -│ ├── document/ 智能文档用例 -│ ├── table/ 智能表格用例 -│ ├── polish/ AI 美文用例 -│ └── meeting/ 会议 AI 总结用例 -├── domain/ -│ ├── task/ AI 任务状态机、幂等、取消、重试 -│ ├── record/ AI 记录模型 -│ ├── quota/ 用量限制、并发限制 -│ └── prompt/ Prompt 模板、变量、输出 Schema -├── repository/ MySQL/Redis 数据访问 -├── integration/ -│ ├── deepseek/ DeepSeek Provider 实现 -│ ├── file/ IM 文件服务与导出文件 -│ ├── meeting/ 会议和会议笔记权限 -│ ├── user/ 用户显示名、组织信息 -│ └── safety/ 内容安全检查 -├── worker/ AI 异步任务消费 -├── parser/ Excel/CSV 解析和采样 -├── exporter/ DOCX/PDF 导出 -└── migrations/ wb_ai_ 表结构和增量脚本 -``` - -目录名称可按实际项目框架调整,但 AI Provider、任务状态机、业务用例和基础设施适配层必须解耦,不能在 Handler 中直接拼 prompt 调 DeepSeek。 - -### 2.3 请求上下文 - -Gateway 或中间件鉴权成功后向 AI 模块传递可信上下文: - -| 字段 | 来源 | 用途 | -| --- | --- | --- | -| `request_id` | `X-Request-ID` 或服务端生成 | 全链路追踪 | -| `tenant_id` | Token/租户解析结果 | 数据隔离和联合索引 | -| `tenant_code` | `X-Tenant-Code`/Token | 日志、配置、文件服务路由 | -| `user_id` | Token | 当前操作用户 | -| `device_id` | Token/请求头 | 多端诊断和风控 | -| `api_key` | `X-MeetingIM-APIKey` | 复用 IM 客户端鉴权 | -| `locale` | 请求头/用户配置 | Prompt 语言和返回文案 | - -### 2.4 外部依赖 - -| 依赖 | 用途 | 失败策略 | -| --- | --- | --- | -| IM Auth/Tenant | Token、租户和用户上下文 | 鉴权失败直接拒绝 | -| IM User/Org | 用户名、组织、参与人展示 | 展示可降级为用户 ID,写权限不可绕过 | -| Workbench Meeting | 会议、会议笔记、附件权限 | 无权限直接拒绝 | -| IM File | 表格源文件下载、会议附件读取、导出文件上传 | 文件不可用则任务失败 | -| DeepSeek API | 文案生成、结构化总结和报告解读 | 超时/失败按策略重试,最终任务失败 | -| MySQL | 任务、记录、用量、幂等、审计 | 核心状态最终一致来源 | -| Redis | 短期配额、并发锁、任务进度缓存 | MySQL 兜底,Redis 失败不放宽限制 | -| Kafka | 异步任务分发 | 不可用时写入任务但保持 `queued`,恢复后补偿扫描 | - ---- - -## 三、DeepSeek 接入方案 - -### 3.1 接入方式 - -DeepSeek 支持 OpenAI 兼容的 Chat Completions API。服务端封装 `AIProvider` 接口,首版实现 `DeepSeekProvider`。 - -推荐配置: - -| 配置项 | 示例 | 说明 | -| --- | --- | --- | -| `AI_PROVIDER` | `deepseek` | 当前 AI Provider | -| `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | OpenAI 兼容接口 base URL | -| `DEEPSEEK_API_KEY` | `******` | DeepSeek API Key,只放密钥系统 | -| `DEEPSEEK_DEFAULT_MODEL` | `deepseek-flash` | 默认模型,适合 AI 美文、智能文档 | -| `DEEPSEEK_COMPLEX_MODEL` | `deepseek-v4-pro` | 复杂表格分析、会议总结可选 | -| `DEEPSEEK_TIMEOUT_MS` | `60000` | 单次请求超时 | -| `DEEPSEEK_MAX_RETRIES` | `2` | 供应商 429/5xx/网络错误重试次数 | -| `DEEPSEEK_TEMPERATURE` | `0.2` | 结构化输出建议较低温度 | -| `DEEPSEEK_MAX_OUTPUT_TOKENS` | `4096` | 输出上限 | - -不建议使用旧模型名 `deepseek-chat`、`deepseek-reasoner` 作为新接入默认值。模型名必须配置化,方便 DeepSeek 后续升级时只改配置。 - -### 3.2 Provider 接口 - -```go -type AIProvider interface { - Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) -} - -type GenerateRequest struct { - Feature string - TaskID string - TenantID string - UserID string - Model string - SystemPrompt string - UserPrompt string - ResponseSchema string - Temperature float64 - MaxTokens int - Timeout time.Duration -} - -type GenerateResponse struct { - ProviderRequestID string - Model string - Content string - PromptTokens int - CompletionTokens int - TotalTokens int - FinishReason string - LatencyMs int64 -} -``` - -业务层只依赖 `AIProvider`,不得直接依赖 DeepSeek HTTP Client。这样后续切换 Provider 或增加备用模型时不影响业务用例。 - -### 3.3 DeepSeek 请求格式 - -请求 DeepSeek 时统一调用: - -```http -POST /chat/completions HTTP/1.1 -Host: api.deepseek.com -Authorization: Bearer ${DEEPSEEK_API_KEY} -Content-Type: application/json -``` - -示例请求: - -```json -{ - "model": "deepseek-flash", - "messages": [ - { - "role": "system", - "content": "你是 IM 工作台的企业办公助手。请严格按 JSON 输出,不要输出额外解释。" - }, - { - "role": "user", - "content": "请将以下日报原文润色为专业、简洁的工作总结..." - } - ], - "temperature": 0.2, - "max_tokens": 800, - "stream": false -} -``` - -首版后端不使用流式输出,统一 `stream=false`。原因是前端当前交互以按钮生成、任务轮询、结果页展示为主;非流式更容易做幂等、审计、失败重试和结构化解析。后续如要做逐字生成,可在 Provider 层扩展流式接口。 - -### 3.4 输出结构化处理 - -DeepSeek 输出必须经过服务端解析和校验,不能直接原样入库给前端。 - -处理流程: - -1. Prompt 明确要求模型只输出 JSON。 -2. Provider 返回文本后,先截取 JSON 主体,去除 Markdown code fence。 -3. 按当前 feature 的 schema 反序列化。 -4. 校验必填字段、数组长度、字符串长度和枚举值。 -5. 校验失败时最多执行一次“修复 JSON”请求,仍失败则任务失败,reason=`AI_TASK_FAILED`。 -6. 校验通过后,生成标准化业务结果并入库。 - -示例:智能文档输出 Schema。 - -```json -{ - "title": "string", - "sections": [ - {"heading": "string", "content": "string"} - ], - "summary": "string" -} -``` - -示例:会议总结输出 Schema。 - -```json -{ - "conclusions": ["string"], - "decisions": ["string"], - "action_items": [ - { - "owner_name": "string", - "content": "string", - "due_text": "string" - } - ] -} -``` - -### 3.5 Prompt 模板管理 - -Prompt 不应散落在业务代码中,建议以数据库配置或内置模板文件管理。首版可内置在代码,并预留版本字段。 - -| 模板 | 用途 | 推荐模型 | 输出 | -| --- | --- | --- | --- | -| `smart_document_v1` | 智能文档生成 | `deepseek-flash` | JSON + markdown | -| `smart_table_v1` | 表格报告解读 | `deepseek-v4-pro` 或 `deepseek-flash` | JSON | -| `text_polish_v1` | AI 美文 | `deepseek-flash` | JSON | -| `meeting_summary_v1` | 会议 AI 总结 | `deepseek-v4-pro` 或 `deepseek-flash` | JSON | - -模板变量示例: - -| 变量 | 说明 | -| --- | --- | -| `{{language}}` | 输出语言 | -| `{{source_text}}` | 用户输入原文 | -| `{{document_type}}` | 文档类型 | -| `{{table_schema}}` | 表头、字段类型、样例数据 | -| `{{table_stats}}` | 服务端预计算统计 | -| `{{meeting_title}}` | 会议标题 | -| `{{meeting_files_text}}` | 会议附件抽取文本 | -| `{{participants}}` | 参会人展示名 | - -Prompt 版本必须写入 `wb_ai_task.prompt_version` 和 `wb_ai_record.prompt_version`,方便回溯同一条记录由哪个模板生成。 - -### 3.6 错误映射 - -| DeepSeek/网络错误 | 服务端 reason | 处理 | -| --- | --- | --- | -| 401/403 | `AI_PROVIDER_AUTH_FAILED` | 配置错误,告警,不重试 | -| 400 | `AI_PROVIDER_BAD_REQUEST` | Prompt 或参数错误,任务失败 | -| 429 | `AI_PROVIDER_RATE_LIMITED` | 按退避重试,仍失败返回 `RATE_LIMITED` | -| 5xx | `AI_PROVIDER_FAILED` | 重试,仍失败任务失败 | -| 网络超时 | `AI_TIMEOUT` | 重试,仍失败任务失败 | -| 输出为空 | `AI_TASK_FAILED` | 可重试一次 | -| JSON 解析失败 | `AI_TASK_FAILED` | 修复 JSON 一次,仍失败则失败 | - -Provider 原始错误只允许写入内部审计表的脱敏字段,不直接返回给前端。 - -### 3.7 超时、重试和熔断 - -- AI 美文同步接口总耗时目标小于 8 秒,超过直接返回 `AI_TIMEOUT`。 -- 智能文档单任务超时 60 秒。 -- 智能表格和会议总结单任务超时 120 秒。 -- 重试仅针对网络错误、429、5xx、输出为空;内容安全、参数错误、权限错误不重试。 -- 重试采用指数退避,例如 1 秒、3 秒。 -- 连续失败率超过阈值时开启 Provider 熔断,新的异步任务保持 `queued` 或快速失败,AI 美文返回“服务繁忙”。 - -### 3.8 安全与脱敏 - -发送 DeepSeek 前必须做最小必要原则: - -1. 智能文档只发送用户输入,不附带用户 Token、手机号、设备信息。 -2. 智能表格只发送表头、数据类型、聚合统计、异常点和必要样例,不发送完整大表。 -3. 会议总结只发送会议标题、附件抽取文本、参会人展示名;不发送内部权限字段。 -4. 日志只记录输入长度、输出长度、hash、模型、耗时、token 用量、错误 reason。 -5. API Key 只从密钥系统或环境变量读取,不入库、不打印、不下发前端。 - ---- - -## 四、数据库设计 - -### 4.1 数据库公共约定 - -- 数据库建议使用 MySQL 8.0,字符集 `utf8mb4`。 -- 所有 AI 表名使用 `wb_ai_` 前缀,主键可使用 `BIGINT UNSIGNED` 或字符串 ID,接口统一返回字符串。 -- 所有租户业务表必须包含 `tenant_id`、`tenant_code`、`user_id`。 -- `created_at`、`updated_at` 使用 `DATETIME(3)`;服务端写 UTC。 -- JSON 字段保存低频扩展内容;状态、类型、时间、用户、任务 ID 必须拆列建索引。 -- AI 原始输入和模型原始输出可按合规要求加密存储或只保存摘要,默认不保存完整供应商响应。 - -### 4.2 表清单 - -| 表名 | 领域 | 用途 | -| --- | --- | --- | -| `wb_ai_capability_config` | 配置 | 租户级 AI 能力开关、模型、限额 | -| `wb_ai_task` | 任务 | AI 异步任务主表 | -| `wb_ai_task_step` | 任务 | 表格等多步骤任务进度 | -| `wb_ai_record` | 记录 | 通用 AI 记录索引 | -| `wb_ai_document` | 智能文档 | 文档保存内容 | -| `wb_ai_table_analysis` | 智能表格 | 表格解读报告 | -| `wb_ai_meeting_summary` | 会议总结 | 会议 AI 总结内容 | -| `wb_ai_usage_log` | 用量 | AI 调用、token、耗时、结果 | -| `wb_ai_idempotency_record` | 通用 | 写接口幂等请求和结果摘要 | -| `wb_ai_prompt_template` | Prompt | Prompt 模板和版本,可后续管理后台维护 | - -### 4.3 `wb_ai_capability_config` — AI 能力配置 - -保存租户维度或默认维度的 AI 能力开关、模型、配额和限制。 - -关键字段: - -| 字段 | 说明 | -| --- | --- | -| `tenant_id` | 租户 ID,默认配置可使用 `0` | -| `feature` | `smart_document`、`smart_table`、`text_polish`、`meeting_summary` | -| `enabled` | 是否开启 | -| `model` | 默认模型,例如 `deepseek-flash` | -| `daily_limit` | 单用户每日次数 | -| `concurrency_limit` | 单用户并发任务数 | -| `max_input_chars` | 输入字符上限 | -| `max_file_size_mb` | 文件大小上限 | -| `config_json` | 扩展配置 | - -关键索引: - -- `uk_tenant_feature(tenant_id, feature)`。 -- 查询时先查租户配置,查不到再用默认配置。 - -### 4.4 `wb_ai_task` — AI 任务主表 - -智能文档、智能表格、会议总结都创建任务。AI 美文首版可同步处理,不强制落任务,但仍写 `wb_ai_usage_log`。 - -关键字段: - -| 字段 | 说明 | -| --- | --- | -| `task_id` | 对外任务 ID,例如 `aitask_xxx` | -| `tenant_id`、`tenant_code`、`user_id` | 数据隔离 | -| `feature` | AI 能力类型 | -| `status` | `queued`、`processing`、`succeeded`、`failed`、`canceled`、`expired` | -| `progress` | 0~100 | -| `source_type` | `text`、`file`、`meeting` | -| `source_ref_id` | 关联文件、会议或业务对象 ID | -| `input_hash` | 输入内容 hash,用于审计和幂等诊断 | -| `input_snapshot` | 脱敏后的输入快照,可按合规决定是否为空 | -| `prompt_version` | Prompt 模板版本 | -| `model` | 实际调用模型 | -| `result_ref_id` | 成功后的记录 ID | -| `error_reason`、`error_message` | 失败原因 | -| `retry_count` | 已重试次数 | -| `started_at`、`finished_at` | 执行时间 | - -关键索引: - -- `uk_task_id(task_id)`。 -- `idx_user_feature_time(tenant_id, user_id, feature, created_at)`。 -- `idx_status_time(status, created_at)`,用于 Worker 拉取和补偿。 - -### 4.5 `wb_ai_task_step` — AI 任务步骤 - -用于智能表格进度展示,也可复用到会议总结。 - -字段包括 `task_id`、`step`、`title`、`status`、`progress`、`started_at`、`finished_at`、`error_reason`。 - -智能表格固定步骤: - -1. `read_file`:读取表格数据。 -2. `detect_metrics`:识别核心指标。 -3. `analyze_trends`:分析趋势变化。 -4. `generate_report`:生成解读报告。 - -### 4.6 `wb_ai_record` — AI 记录索引 - -保存智能文档、智能表格等记录列表所需公共字段。 - -关键字段: - -| 字段 | 说明 | -| --- | --- | -| `record_id` | 对外记录 ID,例如 `airec_doc_xxx` | -| `record_type` | `smart_document`、`smart_table`、`meeting_summary` | -| `title` | 标题 | -| `summary` | 摘要 | -| `status` | `succeeded`、`deleted` 等 | -| `detail_table` | 详情表名或类型 | -| `detail_id` | 详情表 ID | -| `task_id` | 来源任务 | -| `created_at`、`updated_at`、`deleted_at` | 时间 | - -关键索引: - -- `uk_record_id(record_id)`。 -- `idx_user_type_time(tenant_id, user_id, record_type, created_at)`。 -- `idx_title_search(tenant_id, user_id, title)`,实际搜索可接全文索引或外部搜索。 - -### 4.7 `wb_ai_document` — 智能文档详情 - -保存用户确认保存后的智能文档。 - -关键字段: - -- `record_id` -- `document_type` -- `title` -- `content_markdown` -- `content_plain` -- `sections_json` -- `source_excerpt` -- `prompt_version` -- `model` - -智能文档生成成功但未保存时,只保留在 `wb_ai_task.result_json` 或短期缓存中;过期后不可保存。 - -### 4.8 `wb_ai_table_analysis` — 表格解读详情 - -保存表格报告解读结果。 - -关键字段: - -- `record_id` -- `file_id`、`file_name`、`file_type`、`file_size` -- `topic` -- `metrics_json` -- `preview_json` -- `alerts_json` -- `interpretation` -- `export_file_id` -- `prompt_version` -- `model` - -文件解析结果不建议完整入库。大表只保存字段统计、样例行和 AI 输出,完整源文件仍在 IM 文件服务中。 - -### 4.9 `wb_ai_meeting_summary` — 会议 AI 总结 - -保存会议维度的 AI 总结,一个会议默认只有一份当前有效总结。 - -关键字段: - -- `summary_id` -- `meeting_id` -- `task_id` -- `conclusions_json` -- `decisions_json` -- `action_items_json` -- `source_file_ids_json` -- `edited_by` -- `edited_at` -- `version` - -关键索引: - -- `uk_tenant_meeting(tenant_id, meeting_id)`。 -- `idx_user_time(tenant_id, user_id, updated_at)`。 - -编辑保存时使用 `version` 做乐观锁,避免多端同时编辑覆盖。 - -### 4.10 `wb_ai_usage_log` — AI 用量日志 - -每次调用 DeepSeek,无论成功失败都写用量日志。 - -字段包括: - -- `tenant_id`、`tenant_code`、`user_id` -- `feature` -- `task_id` -- `provider=deepseek` -- `model` -- `provider_request_id` -- `prompt_tokens`、`completion_tokens`、`total_tokens` -- `latency_ms` -- `status` -- `error_reason` -- `input_hash` -- `output_hash` -- `created_at` - -用量统计优先从该表聚合,日配额可用 Redis 加速、MySQL 兜底。 - -### 4.11 `wb_ai_idempotency_record` — 幂等记录 - -写接口统一记录幂等键、请求 hash 和响应摘要。 - -关键约束: - -- `uk_idempotency(tenant_id, user_id, idempotency_key)`。 -- 同一 key 请求 hash 相同,直接返回第一次响应。 -- 同一 key 请求 hash 不同,返回 `IDEMPOTENCY_CONFLICT`。 - ---- - -## 五、核心业务设计 - -### 5.1 AI 能力配置读取 - -`GET /v1/workbench/ai/capabilities` 流程: - -1. 解析 IM Token,得到租户和用户。 -2. 读取租户 AI 配置;租户没有配置时读取默认配置。 -3. 查询用户当天各 feature 已使用次数。 -4. 组装 `features[]`,返回开关、入口名、每日限制、剩余次数、输入限制、文件限制。 - -配置建议缓存 1 分钟。配置变化后可通过后台主动清缓存,首版可接受短延迟。 - -### 5.2 AI 美文同步流程 - -AI 美文用于短文本润色,流程: - -```text -请求进入 - -> 鉴权和租户解析 - -> 参数校验 source_text <= 300 - -> 能力开关、日配额、频控校验 - -> 内容安全预检查 - -> 构造 text_polish_v1 prompt - -> 调用 DeepSeek - -> 解析 JSON 输出 polished_text - -> 内容安全后检查 - -> 写 usage_log - -> 返回 polished_text -``` - -失败处理: - -- DeepSeek 超时:返回 `AI_TIMEOUT`。 -- 输出解析失败:重试一次;仍失败返回 `AI_TASK_FAILED`。 -- 内容安全失败:返回 `AI_CONTENT_REJECTED`。 -- 用量超限:返回 `AI_QUOTA_EXCEEDED`。 - -AI 美文不自动提交日报/月报,原业务提交接口保持不变。 - -### 5.3 智能文档流程 - -创建任务: - -1. 校验输入长度、能力开关、配额和幂等。 -2. 写 `wb_ai_task(status=queued, feature=smart_document)`。 -3. 投递 Kafka 消息或写入本地任务队列。 -4. 返回 `task_id` 给前端轮询。 - -Worker 执行: - -1. 将任务置为 `processing`。 -2. 构造 `smart_document_v1` prompt。 -3. 调用 DeepSeek。 -4. 解析 `title`、`sections`、`content_markdown`、`summary`。 -5. 结果写入 `wb_ai_task.result_json`,任务置为 `succeeded`。 -6. 用户点击保存后,写 `wb_ai_record` 和 `wb_ai_document`。 - -注意: - -- 生成成功不等于保存成功。 -- 未保存结果可设置 2 小时过期。 -- 重新生成创建新任务,不覆盖旧任务。 - -### 5.4 智能表格流程 - -智能表格必须先上传文件,再创建任务。 - -创建任务: - -1. 校验 `file_id` 属于当前租户和用户。 -2. 校验文件类型为 `xlsx/xls/csv`,大小不超过配置。 -3. 写 `wb_ai_task`、`wb_ai_task_step`,预生成 `record_id`。 -4. 投递任务,返回 `task_id`、`record_id`。 - -Worker 执行: - -```text -read_file - -> 从 IM 文件服务下载文件 - -> 解析 xlsx/xls/csv - -> 抽取表头、字段类型、样例行、总行数 -detect_metrics - -> 服务端先做基础统计:合计、均值、最大最小、环比、空值、异常点 -analyze_trends - -> 将表结构、统计摘要、异常点输入 DeepSeek -generate_report - -> 解析模型 JSON,生成指标、预览、异常提醒、核心解读 - -> 写 wb_ai_record、wb_ai_table_analysis -``` - -表格数据处理原则: - -- 不把完整大表直接发给 DeepSeek。 -- 优先由服务端计算确定性统计,再让 AI 解释。 -- 样例行数量限制,例如最多 20 行。 -- 敏感列可按列名规则脱敏,例如手机号、身份证、邮箱。 - -### 5.5 会议 AI 总结流程 - -会议 AI 总结必须复用会议笔记权限。 - -流程: - -1. 校验当前用户可查看该会议笔记。 -2. 获取会议标题、时间、组织者、参会人、附件列表。 -3. 校验 `file_ids` 均属于该会议且当前用户可读。 -4. 抽取附件文本;若附件为空,可基于会议描述和已有笔记文本生成,但必须有有效输入。 -5. 创建 `meeting_summary` AI 任务。 -6. Worker 调用 DeepSeek 生成结论、关键决策、后续安排。 -7. 写入或更新 `wb_ai_meeting_summary`。 - -编辑保存: - -- 校验会议笔记编辑权限。 -- 使用 `version` 乐观锁。 -- 保存用户编辑内容,记录 `edited_by`、`edited_at`。 - -### 5.6 导出报告流程 - -表格报告导出流程: - -1. 校验记录属于当前用户。 -2. 读取 `wb_ai_table_analysis`。 -3. 使用服务端模板生成 DOCX 或 PDF。 -4. 上传到 IM 文件服务,得到 `file_id`。 -5. 返回下载 URL 和过期时间。 - -导出不再调用 DeepSeek,避免同一报告重复生成内容不一致。 - -### 5.7 应用搜索接入 - -工作台应用列表和搜索本质不是 AI Provider 调用,但需要接入 AI 能力配置。 - -处理方式: - -- 应用注册表增加 AI 应用:`smart_document`、`smart_table`。 -- 返回应用列表前读取 capabilities。 -- 未开启的 AI 应用不返回,或返回 `enabled=false`,由产品决定。 -- 搜索只搜索当前用户可见应用。 - ---- - -## 六、关键技术方案 - -### 6.1 事务边界 - -需要事务的场景: - -- 创建 AI 任务和幂等记录。 -- 表格任务成功后写记录索引和详情。 -- 智能文档保存时写记录索引和详情。 -- 会议总结更新和版本递增。 -- 用量日志和日配额计数。 - -不应放入同一事务的场景: - -- 调用 DeepSeek。 -- 下载文件和解析大文件。 -- 导出文件上传。 -- 发送 WebSocket 或系统通知。 - -外部调用必须在事务外执行,事务只保护本地状态变更。 - -### 6.2 幂等与弱网 - -所有写接口都要求 `Idempotency-Key`: - -1. 请求进入后计算规范化请求体 hash。 -2. 查询 `wb_ai_idempotency_record`。 -3. 不存在则插入 `processing` 记录并继续。 -4. 已存在且 hash 相同,返回首次响应。 -5. 已存在且 hash 不同,返回 `IDEMPOTENCY_CONFLICT`。 -6. 业务成功后更新响应摘要。 - -异步任务的幂等响应应返回同一个 `task_id`,不能重复创建任务。 - -### 6.3 任务调度 - -首选 Kafka: - -| Topic | 用途 | -| --- | --- | -| `workbench.ai.task.created` | AI 任务创建 | -| `workbench.ai.task.retry` | AI 任务重试 | -| `workbench.ai.task.completed` | AI 任务完成事件,可选 | - -消息体: - -```json -{ - "task_id": "aitask_tbl_2Fh9aQx73K", - "tenant_id": "1", - "tenant_code": "system", - "user_id": "usrABAAnqj42BU", - "feature": "smart_table", - "created_at": "2026-09-25T08:30:15.123Z" -} -``` - -消费要求: - -- Consumer 收到消息后先按 `task_id` 查询 DB,只有 `queued` 可执行。 -- 任务置为 `processing` 使用条件更新,避免多 Consumer 重复执行。 -- 执行失败按错误类型决定是否重试。 -- 服务重启后通过补偿任务扫描 `queued` 和超时 `processing`。 - -如果首版 Kafka 未就绪,可用 MySQL 轮询表兜底,但接口设计保持不变。 - -### 6.4 并发与配额 - -配额维度: - -- 租户总开关。 -- 单用户每日次数。 -- 单用户并发任务数。 -- 单租户并发任务数。 -- Provider 全局并发数。 - -实现建议: - -- Redis 维护短期计数和并发锁。 -- MySQL `wb_ai_usage_log` 做最终用量统计。 -- Redis 不可用时,使用 MySQL 当天聚合兜底,宁可保守拒绝,不放宽限制。 - -### 6.5 表格解析 - -解析规则: - -- `xlsx/xls`:读取第一个非空 sheet;如果前端指定 `sheet_name`,必须校验存在。 -- `csv`:自动识别 UTF-8/GBK,分隔符支持逗号,必要时兼容制表符。 -- 空行、合并单元格、公式结果需要规范化。 -- 超过最大行数时只采样前 N 行,并计算全量统计时注意内存限制。 - -输出给 DeepSeek 的内容建议: - -```json -{ - "topic": "8月销售数据", - "columns": [ - {"name": "日期", "type": "date"}, - {"name": "销售额", "type": "number"}, - {"name": "订单量", "type": "number"} - ], - "row_count": 31, - "sample_rows": [ - ["8/1", "42300", "126"] - ], - "stats": { - "销售额": {"sum": 1286000, "max": 62000, "min": 32100}, - "订单量": {"sum": 3842} - }, - "outliers": [ - {"row": 15, "column": "订单量", "description": "较前一日下降 18%"} - ] -} -``` - -### 6.6 内容安全 - -内容安全分为前置检查和后置检查: - -- 前置检查:用户输入、表格标题、会议附件抽取文本。 -- 后置检查:DeepSeek 输出结果。 - -首版如没有独立内容安全服务,可先做基础敏感词、长度、HTML/script 清理和文件类型校验,同时预留 `safety` adapter。命中安全规则返回 `AI_CONTENT_REJECTED`。 - -### 6.7 缓存策略 - -| 缓存 | Key | TTL | 说明 | -| --- | --- | --- | --- | -| AI 能力配置 | `wb:ai:cap:{tenant_id}` | 60s | 能力开关和限制 | -| 用户日用量 | `wb:ai:quota:{tenant_id}:{user_id}:{date}` | 当天 | 快速判断剩余次数 | -| 任务进度 | `wb:ai:task:{task_id}` | 2h | 轮询加速 | -| Provider 熔断 | `wb:ai:provider:deepseek:circuit` | 30s | 熔断标记 | - -缓存不可作为唯一事实来源。任务最终状态以 MySQL 为准。 - -### 6.8 错误处理 - -对前端统一返回 workbench 错误包: - -```json -{ - "code": 30050, - "reason": "AI_TIMEOUT", - "message": "ai timeout", - "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", - "server_time": "2026-09-25T08:30:15.123Z" -} -``` - -错误分层: - -- 参数和权限错误:接口同步返回。 -- 异步任务执行错误:任务置为 `failed`,轮询接口返回 `error`。 -- Provider 配置错误:接口返回失败并触发告警。 -- Kafka 不可用:任务保持 `queued`,补偿扫描重投。 - -### 6.9 可观测性 - -日志字段: - -- `request_id` -- `tenant_code` -- `user_id_hash` -- `feature` -- `task_id` -- `provider` -- `model` -- `status` -- `latency_ms` -- `prompt_tokens` -- `completion_tokens` -- `error_reason` - -指标: - -- AI 请求数、成功数、失败数。 -- DeepSeek 调用耗时 P50/P90/P99。 -- 任务排队时长、执行时长。 -- 任务失败率、重试次数。 -- 每租户 token 用量。 -- Provider 429/5xx/超时次数。 - -告警: - -- DeepSeek 连续 5 分钟失败率超过阈值。 -- 任务积压超过阈值。 -- Kafka 消费延迟超过阈值。 -- API Key 鉴权失败。 -- 单租户用量异常增长。 - ---- - -## 七、C 端与管理后台需求 - -### 7.1 C 端职责边界 - -C 端负责: - -- 输入长度和文件类型的第一层校验。 -- 调用任务创建接口并轮询。 -- 展示 AI 结果、失败、重试、取消和空态。 -- 在日报/月报中使用 AI 美文结果回填字段。 -- 展示“由 AI 生成,请核对后使用”提示。 - -C 端不负责: - -- 判断最终权限。 -- 控制真实用量。 -- 保存 DeepSeek Key。 -- 拼接最终 prompt。 -- 解析供应商原始响应。 - -### 7.2 管理后台能力 - -建议后续在 manager 或 workbench admin 增加 AI 配置: - -| 能力 | 说明 | -| --- | --- | -| 租户 AI 总开关 | 控制某租户是否开放 AI | -| 功能开关 | 分别控制智能文档、智能表格、AI 美文、会议总结 | -| 模型配置 | 每个功能选择默认模型 | -| 每日限额 | 单用户每日次数、租户每日总次数 | -| 文件限制 | 表格文件大小、行数、类型 | -| Prompt 版本 | 查看当前模板版本,灰度新模板 | -| 用量统计 | 按租户、用户、功能统计 token 和次数 | -| 失败日志 | 查看脱敏后的失败原因和任务 ID | - -### 7.3 运维配置 - -Kubernetes/Gitea Secrets 中需要配置: - -```text -AI_PROVIDER=deepseek -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_API_KEY= -DEEPSEEK_DEFAULT_MODEL=deepseek-flash -DEEPSEEK_COMPLEX_MODEL=deepseek-v4-pro -DEEPSEEK_TIMEOUT_MS=60000 -DEEPSEEK_MAX_RETRIES=2 -DEEPSEEK_TEMPERATURE=0.2 -``` - -注意: - -- `DEEPSEEK_API_KEY` 只能放 Secret,不放 ConfigMap,不提交 Git。 -- 发版前确认 Pod 环境变量或配置文件最终生效。 -- 模型名必须和 DeepSeek 当前可用模型一致。 -- 不同环境可使用不同 Key 和限额。 - ---- - -## 八、开发阶段与依赖 - -### 8.1 开发阶段 - -#### 阶段一:基础框架和 AI 美文 - -目标: - -- 建表:能力配置、用量日志、幂等。 -- 实现 DeepSeek Provider。 -- 实现 AI 美文接口。 -- 接入日报/月报回填流程。 - -验收: - -- 能通过 DeepSeek 返回润色文案。 -- 超时、Key 错误、用量超限有明确错误。 -- 日报/月报原业务提交不受影响。 - -#### 阶段二:智能文档 - -目标: - -- 实现 AI 任务表、任务轮询、智能文档生成、保存、记录列表。 -- 接入 Kafka 或 MySQL 任务队列。 - -验收: - -- 能生成文档、重新生成、保存、查询记录。 -- 弱网重复提交不创建重复任务。 - -#### 阶段三:智能表格 - -目标: - -- 文件归属校验、Excel/CSV 解析、统计摘要、DeepSeek 报告生成、记录详情。 -- 实现导出 DOCX/PDF。 - -验收: - -- xlsx/xls/csv 都能分析。 -- 大文件不会把完整内容发给 DeepSeek。 -- 任务进度和失败状态可见。 - -#### 阶段四:会议 AI 总结 - -目标: - -- 接入会议笔记权限和附件。 -- 生成会议结论、关键决策、后续安排。 -- 支持编辑保存。 - -验收: - -- 无权限不能生成或查看。 -- 会议附件不可用时有明确失败原因。 -- 编辑保存不会被任务结果覆盖。 - -### 8.2 开发依赖 - -| 依赖 | 负责人 | 说明 | -| --- | --- | --- | -| DeepSeek API Key | 运维/后端 | 测试、生产分别配置 | -| Kafka | 后端/运维 | AI 异步任务队列 | -| Redis | 后端/运维 | 配额、进度缓存、并发锁 | -| 文件服务 | IM 后端 | 表格源文件下载、导出文件上传 | -| 会议笔记接口 | workbench 后端 | 会议权限、附件列表 | -| 前端页面 | 前端 | AI 入口、轮询、结果、记录 | - ---- - -## 九、发布与回滚 - -### 9.1 发布顺序 - -1. 发布数据库 migration。 -2. 配置 DeepSeek Secret、AI Provider、模型和限额。 -3. 发布后端代码,默认租户 AI 开关关闭。 -4. 验证 Provider 健康检查和 AI 美文测试接口。 -5. 灰度开启测试租户 AI 美文。 -6. 依次开启智能文档、智能表格、会议总结。 -7. 前端打开入口。 - -### 9.2 发布检查 - -- Pod 中 `DEEPSEEK_API_KEY` 已注入,但日志不打印明文。 -- `DEEPSEEK_BASE_URL` 为 `https://api.deepseek.com`。 -- 默认模型可调用。 -- Kafka consumer 正常消费。 -- Redis 可写入配额 key。 -- MySQL 表和索引已创建。 -- 文件服务可下载测试表格。 -- 任务失败告警已配置。 - -### 9.3 回滚策略 - -| 问题 | 回滚方式 | -| --- | --- | -| DeepSeek 大面积失败 | 关闭租户 AI 开关或 Provider 熔断 | -| AI 美文影响日报/月报 | 隐藏 AI 美文入口,原提交接口不回滚 | -| 智能文档失败率高 | 关闭 `smart_document` 功能 | -| 智能表格解析异常 | 关闭 `smart_table` 功能,保留历史记录只读 | -| 会议总结权限问题 | 关闭 `meeting_summary` 功能,保留会议笔记原能力 | -| 新表结构问题 | 代码回滚到不访问新表版本;migration 回滚需单独评估 | - -回滚时不删除已生成记录。前端入口关闭后,历史记录可按产品要求保留只读或隐藏。 - ---- - -## 附录 A:MySQL 8.0 建表草案 - -```sql -CREATE TABLE wb_ai_capability_config ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - tenant_code VARCHAR(64) NOT NULL DEFAULT '', - feature VARCHAR(64) NOT NULL, - enabled TINYINT(1) NOT NULL DEFAULT 0, - model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', - daily_limit INT NOT NULL DEFAULT 0, - concurrency_limit INT NOT NULL DEFAULT 1, - max_input_chars INT NOT NULL DEFAULT 0, - max_file_size_mb INT NOT NULL DEFAULT 0, - config_json JSON NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_tenant_feature (tenant_id, feature) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_task ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - task_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - feature VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - progress INT NOT NULL DEFAULT 0, - source_type VARCHAR(32) NOT NULL DEFAULT '', - source_ref_id VARCHAR(128) NOT NULL DEFAULT '', - input_hash VARCHAR(128) NOT NULL DEFAULT '', - input_snapshot JSON NULL, - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - result_ref_id VARCHAR(64) NOT NULL DEFAULT '', - result_json JSON NULL, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - error_message VARCHAR(255) NOT NULL DEFAULT '', - retry_count INT NOT NULL DEFAULT 0, - started_at DATETIME(3) NULL, - finished_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_task_id (task_id), - KEY idx_user_feature_time (tenant_id, user_id, feature, created_at), - KEY idx_status_time (status, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_task_step ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - task_id VARCHAR(64) NOT NULL, - step VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - status VARCHAR(32) NOT NULL, - progress INT NOT NULL DEFAULT 0, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - started_at DATETIME(3) NULL, - finished_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_task_step (task_id, step) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_record ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - record_type VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - summary VARCHAR(512) NOT NULL DEFAULT '', - status VARCHAR(32) NOT NULL, - detail_id VARCHAR(64) NOT NULL DEFAULT '', - task_id VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - deleted_at DATETIME(3) NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id), - KEY idx_user_type_time (tenant_id, user_id, record_type, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_document ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - document_type VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - content_markdown MEDIUMTEXT NOT NULL, - content_plain MEDIUMTEXT NULL, - sections_json JSON NULL, - source_excerpt VARCHAR(1024) NOT NULL DEFAULT '', - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_table_analysis ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - file_id VARCHAR(128) NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_type VARCHAR(16) NOT NULL, - file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, - topic VARCHAR(128) NOT NULL, - metrics_json JSON NULL, - preview_json JSON NULL, - alerts_json JSON NULL, - interpretation MEDIUMTEXT NULL, - export_file_id VARCHAR(128) NOT NULL DEFAULT '', - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id), - KEY idx_file_id (tenant_id, file_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_meeting_summary ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - summary_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - meeting_id VARCHAR(128) NOT NULL, - user_id VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - conclusions_json JSON NULL, - decisions_json JSON NULL, - action_items_json JSON NULL, - source_file_ids_json JSON NULL, - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - edited_by VARCHAR(64) NOT NULL DEFAULT '', - edited_at DATETIME(3) NULL, - version INT NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_summary_id (summary_id), - UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_usage_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - feature VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - provider VARCHAR(32) NOT NULL, - model VARCHAR(64) NOT NULL, - provider_request_id VARCHAR(128) NOT NULL DEFAULT '', - prompt_tokens INT NOT NULL DEFAULT 0, - completion_tokens INT NOT NULL DEFAULT 0, - total_tokens INT NOT NULL DEFAULT 0, - latency_ms INT NOT NULL DEFAULT 0, - status VARCHAR(32) NOT NULL, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - input_hash VARCHAR(128) NOT NULL DEFAULT '', - output_hash VARCHAR(128) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - KEY idx_user_feature_day (tenant_id, user_id, feature, created_at), - KEY idx_provider_time (provider, model, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE wb_ai_idempotency_record ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - idempotency_key VARCHAR(128) NOT NULL, - request_hash VARCHAR(128) NOT NULL, - response_code INT NOT NULL DEFAULT 0, - response_body JSON NULL, - status VARCHAR(32) NOT NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -``` - -## 附录 B:DeepSeek 调用伪代码 - -```go -func (p *DeepSeekProvider) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { - body := map[string]any{ - "model": req.Model, - "messages": []map[string]string{ - {"role": "system", "content": req.SystemPrompt}, - {"role": "user", "content": req.UserPrompt}, - }, - "temperature": req.Temperature, - "max_tokens": req.MaxTokens, - "stream": false, - } - - httpReq := NewJSONRequest(ctx, "POST", p.baseURL+"/chat/completions", body) - httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) - - resp, err := p.httpClient.Do(httpReq) - if err != nil { - return GenerateResponse{}, mapNetworkError(err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return GenerateResponse{}, mapDeepSeekStatus(resp.StatusCode, resp.Body) - } - - parsed := parseChatCompletion(resp.Body) - return GenerateResponse{ - ProviderRequestID: parsed.ID, - Model: parsed.Model, - Content: parsed.Choices[0].Message.Content, - PromptTokens: parsed.Usage.PromptTokens, - CompletionTokens: parsed.Usage.CompletionTokens, - TotalTokens: parsed.Usage.TotalTokens, - FinishReason: parsed.Choices[0].FinishReason, - LatencyMs: elapsed.Milliseconds(), - }, nil -} -``` - -## 附录 C:已确认产品与技术决策 - -| 决策 | 说明 | -| --- | --- | -| AI Provider | 首版接入 DeepSeek | -| 接口形态 | DeepSeek 走 OpenAI 兼容 Chat Completions | -| 默认模型 | `deepseek-flash` | -| 复杂任务模型 | 可配置为 `deepseek-v4-pro` | -| 流式输出 | 首版不做流式,统一非流式和任务轮询 | -| 表格处理 | 服务端先解析和统计,不把完整大表直接发给模型 | -| 日报/月报 | AI 只润色文案,不改变原提交接口 | -| 会议总结 | 必须复用会议笔记权限 | -| 日志 | 不打印完整输入、输出、Token、API Key | diff --git a/docs/IM工作台AI接入需求文档.md b/docs/IM工作台AI接入需求文档.md deleted file mode 100644 index 48d6ffc..0000000 --- a/docs/IM工作台AI接入需求文档.md +++ /dev/null @@ -1,1302 +0,0 @@ -# IM 工作台 AI 接入需求文档 - -> 本文档根据 `docs/ai/` 目录下的工作台 AI 原型图整理,并参照 `docs/IM-产品原型需求文档.md` 的编写方式补充产品流程、前后端接口、服务端任务、数据存储和异常处理要求。原型图路径均相对于本文档所在 `docs/` 目录。 - -## 产品总体定位 - -工作台 AI 是 IM 企业工作台中的智能办公能力集合,目标是在不打断原有工作台、日报、月报、会议笔记等业务流程的前提下,为用户提供文本优化、文档生成、表格解读、会议总结和办公应用检索能力。 - -首版能力聚焦 5 类场景: - -1. 工作台入口与应用搜索:在工作台首页展示 AI 能力入口,并支持搜索工作台应用。 -2. 智能文档:根据用户输入的核心内容生成结构化文档,并支持保存和查看生成记录。 -3. 智能表格:上传 Excel/CSV 报表,由 AI 生成指标、数据预览、异常提醒和核心解读,并支持导出报告。 -4. AI 美文:对日报、月报等用户输入内容进行语言润色优化,支持重新生成和使用文案。 -5. 会议笔记 AI 总结:基于会议资料、会议笔记或上传文件生成会议结论、关键决策和后续安排。 - -系统设计上,AI 生成类功能必须采用异步任务模型,保留任务状态、输入内容、文件来源、生成结果、错误原因和用户操作记录;同时必须保证租户隔离、用户权限校验、敏感信息保护和生成结果可追溯。 - -## 0. 通用约定 - -### 0.1 入口与鉴权 - -工作台 AI 接口统一挂载在工作台服务下: - -```text -/v1/workbench/ai/* -``` - -公共请求头沿用工作台接口约定: - -```http -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: -X-Request-ID: -Idempotency-Key: # 写请求必传 -``` - -服务端从 IM 登录态解析当前用户和租户,不允许前端在请求体中伪造 `tenant_id`、`user_id`、`internal_user_id`。 - -### 0.2 租户隔离 - -AI 任务、记录、上传文件和导出文件必须按租户隔离。 - -要求: - -1. 所有 AI 数据表必须包含 `tenant_id`。 -2. 查询记录时只能返回当前租户、当前用户有权限查看的数据。 -3. 会议总结类任务必须校验当前用户是否可查看该会议笔记。 -4. 日报、月报 AI 美文只允许操作当前用户本人正在编辑的草稿内容。 -5. 后台管理或运营查询必须走独立内部接口,并记录审计日志。 - -### 0.3 AI 内容安全 - -AI 生成内容需要明确展示“由 AI 生成,请核对后使用”一类提示。前端不得默认替用户提交 AI 结果,必须由用户主动点击“使用文案”“保存文档”“提交”“导出”等操作。 - -服务端要求: - -1. 保存原始输入和 AI 输出,便于用户追溯和问题排查。 -2. 不把用户输入、AI 输出、文件内容直接写入普通日志。 -3. 调用第三方 AI 服务时,按租户和用户记录调用量、token 用量、耗时、状态和错误码。 -4. AI 输出失败、超时、内容违规时返回稳定错误码,前端展示可重试提示。 -5. 对上传文件做类型、大小、行数和解析安全校验,避免公式注入、超大文件和恶意内容影响服务。 - -### 0.4 任务状态 - -AI 生成类任务统一使用如下状态: - -| 状态 | 含义 | 前端表现 | -| --- | --- | --- | -| `pending` | 已创建,等待执行 | 显示排队中 | -| `processing` | 正在处理 | 显示进度页或 loading | -| `succeeded` | 生成成功 | 展示结果,可保存/导出/使用 | -| `failed` | 生成失败 | 展示失败原因和重试入口 | -| `canceled` | 用户取消 | 返回上一页或记录为已取消 | -| `expired` | 临时结果过期 | 提示重新生成 | - -表格解读可额外返回分阶段进度: - -| 阶段 | 含义 | -| --- | --- | -| `read_table` | 读取表格数据 | -| `extract_metrics` | 识别核心指标 | -| `analyze_trends` | 分析趋势变化 | -| `generate_report` | 生成解读报告 | - -### 0.5 统一响应 - -成功响应: - -```json -{ - "code": 0, - "data": {}, - "request_id": "uuid", - "server_time": "2026-09-25T10:00:00Z" -} -``` - -失败响应: - -```json -{ - "code": 30000, - "reason": "MALFORMED_REQUEST", - "message": "malformed request", - "details": { - "field": "title" - }, - "request_id": "uuid", - "server_time": "2026-09-25T10:00:00Z" -} -``` - -## 1. 工作台首页与 AI 入口 - -### 1.1 工作台.png - -工作台 - -功能说明: - -工作台首页展示顶部搜索框、AI 欢迎区、常用软件和全部应用。AI 欢迎区使用机器人形象和提示文案“你好,开始高效办公吧 / 整理文档、解读报表,让工作更轻松”,用于引导用户进入智能办公能力。 - -常用软件首版包含: - -1. 打卡 -2. 日报 -3. 智能文档 -4. 智能表格 - -全部应用首版包含: - -1. 部门框架 -2. 举报反馈 -3. 会议笔记 -4. 会议通知 -5. 月报 - -业务规则: - -1. 工作台首页应用列表由服务端返回,前端按服务端排序展示。 -2. `智能文档` 和 `智能表格` 需要支持开关控制,租户未启用 AI 时不展示或置灰。 -3. 首页未读角标沿用工作台既有能力,例如会议通知、工作台入口底部角标。 -4. AI 欢迎区仅作为引导,不直接触发生成任务。 -5. 页面加载失败时,保留本地缓存应用列表,并提示稍后重试。 - -建议接口: - -```http -GET /v1/workbench/ai/capabilities -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "enabled": true, - "features": { - "smart_document": true, - "smart_table": true, - "text_polish": true, - "meeting_summary": true - }, - "limits": { - "document_input_max_chars": 1000, - "text_polish_max_chars": 300, - "table_file_max_size": 10485760 - } - } -} -``` - -## 2. 工作台应用搜索 - -### 2.1 工作台-搜索页面.png - -工作台-搜索页面 - -功能说明: - -用户点击工作台首页搜索框后进入搜索页。页面顶部展示返回按钮、搜索输入框和“取消”按钮;默认展示常用应用宫格,包括打卡、日报、智能文档、智能表格、部门框架、举报反馈、会议笔记、会议通知。 - -业务规则: - -1. 搜索框 placeholder 为“搜索应用”。 -2. 未输入关键词时展示常用应用。 -3. 点击“取消”或左上角返回按钮回到工作台首页。 -4. 点击任一应用进入对应功能页。 -5. 搜索结果应只返回当前租户启用、当前用户有权限访问的应用。 - -### 2.2 工作台-搜索页面-明确展示.png - -工作台-搜索页面-明确展示 - -功能说明: - -用户输入关键词后展示“搜索结果”卡片。结果项展示应用图标、应用名称和右侧箭头,点击进入对应应用。 - -业务规则: - -1. 支持按应用名称、别名、拼音首字母进行搜索。 -2. 搜索关键词前后空格需要 trim。 -3. 支持模糊匹配,例如搜索“组织框架”命中“部门框架”或“组织框架”,具体别名由服务端配置。 -4. 搜索结果按常用度、名称匹配度、应用排序综合排序。 - -建议接口: - -```http -GET /v1/workbench/apps/search?q=组织框架 -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "items": [ - { - "app_id": "organization", - "name": "组织框架", - "icon": "organization", - "route": "/workbench/organization", - "enabled": true - } - ] - } -} -``` - -### 2.3 工作台-搜索页面-无结果.png - -工作台-搜索页面-无结果 - -功能说明: - -无匹配应用时展示空状态插画和“暂无匹配结果”文案。 - -业务规则: - -1. 搜索接口返回空数组时展示该状态。 -2. 网络异常不展示“暂无匹配结果”,应展示网络错误或重试提示。 -3. 清空关键词后恢复常用应用列表。 - -## 3. 智能文档 - -智能文档用于将用户输入的核心内容生成结构化办公文档,适合会议纪要、沟通纪要、任务安排、项目说明等轻量文档场景。 - -### 3.1 智能文档(2).png - -智能文档输入空状态 - -功能说明: - -智能文档首页顶部标题为“智能文档”,右上角展示“文档记录”入口。主体区域为“核心内容”输入框,placeholder 为“请输入”,右下角展示字数计数 `0/1000`,底部固定“生成文档”按钮。 - -业务规则: - -1. 核心内容必填,最大 1000 字。 -2. 未输入内容时,“生成文档”按钮可置灰或点击后提示“请输入核心内容”。 -3. 输入超过 1000 字时禁止继续输入或提交时返回字段错误。 -4. 点击“文档记录”进入当前用户的文档生成记录。 -5. 返回按钮退出智能文档页,若有未提交内容,应按产品策略提示是否放弃草稿。 - -### 3.2 智能文档(1).png - -智能文档输入内容 - -功能说明: - -用户输入核心内容后,可点击“生成文档”。输入内容示例为新项目沟通、人员安排、功能范围和待确认事项。 - -生成规则: - -1. AI 应从原始内容中识别主题、沟通事项、功能点、责任人、时间节点和待办事项。 -2. 生成内容应使用清晰标题和分段结构。 -3. 如果输入中包含人员和时间,应尽量保留并结构化。 -4. 不应凭空生成用户未提及的关键事实;可用“待确认”表达不确定项。 - -建议接口: - -```http -POST /v1/workbench/ai/documents/generate -Idempotency-Key: -``` - -请求示例: - -```json -{ - "source_text": "今天讨论新项目,确定新增群聊功能和朋友圈两个入口。小林负责设计,下周三交初稿;小陈负责开发,下周五给排期。首版支持手动输入、总结要点和生成文档。导出格式还需要确认。", - "title_hint": "新项目沟通纪要" -} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_7abc", - "status": "processing" - } -} -``` - -### 3.3 智能文档.png - -智能文档生成结果 - -功能说明: - -文档生成完成后,页面展示 AI 生成文档。顶部保留“智能文档”标题和“编辑”入口;内容顶部显示“由 AI 生成”标签,正文包含文档标题“新项目沟通纪要”和分段内容,例如沟通事项、首版功能、任务安排、待办事项。底部展示“由 AI 整理,请核对后使用”提示,并提供“重新生成”和“保存文档”按钮。 - -业务规则: - -1. AI 结果必须标识来源,不允许伪装成用户手写内容。 -2. “重新生成”使用同一原始输入创建新一版结果,需要保留版本号。 -3. “保存文档”将当前结果保存为文档记录,状态变为 `succeeded`。 -4. “编辑”允许用户对 AI 结果进行人工修改,修改后保存的是用户确认后的最终内容。 -5. 保存成功后可进入详情或返回工作台,具体交互由客户端确定。 -6. 如果 AI 生成失败,展示失败状态和重试按钮。 - -生成结果查询接口: - -```http -GET /v1/workbench/ai/tasks/{task_id} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_7abc", - "task_type": "smart_document", - "status": "succeeded", - "result": { - "title": "新项目沟通纪要", - "content_markdown": "一、沟通事项\n\n新增群聊功能和朋友圈两个入口。\n\n二、首版功能\n\n1. 手动输入重点内容。\n2. 智能总结要点。\n3. 生成结构化文档。", - "ai_disclaimer": "由 AI 整理,请核对后使用" - } - } -} -``` - -保存接口: - -```http -POST /v1/workbench/ai/documents -Idempotency-Key: -``` - -请求示例: - -```json -{ - "task_id": "aitask_7abc", - "title": "新项目沟通纪要", - "content_markdown": "..." -} -``` - -### 3.4 总结记录(1).png - -文档记录 - -功能说明: - -文档记录页展示当前用户保存过的智能文档记录。顶部为关闭按钮、标题“文档记录”和搜索框。列表按日期分组,例如“今天”;每条记录展示 Word 图标、标题、文件类型、时间、状态“已完成”和右侧箭头。 - -业务规则: - -1. 仅展示当前用户本人创建或有权限查看的文档记录。 -2. 支持按标题搜索。 -3. 记录按创建时间倒序排列。 -4. 状态可展示 `已完成`、`生成中`、`失败`。 -5. 点击记录进入文档详情。 - -建议接口: - -```http -GET /v1/workbench/ai/records?type=smart_document&query=&cursor=&limit=20 -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "items": [ - { - "record_id": "airec_doc_1", - "type": "smart_document", - "title": "8月销售数据分析", - "file_type": "word", - "status": "succeeded", - "display_status": "已完成", - "created_at": "2026-09-25T02:30:00Z" - } - ], - "next_cursor": "", - "has_more": false - } -} -``` - -## 4. 智能表格与报告解读 - -智能表格用于上传 Excel 或 CSV 报表,AI 自动读取表格数据、识别核心指标、分析趋势变化,并生成解读报告。 - -### 4.1 智能表格.png - -智能表格 - -功能说明: - -页面标题区包含关闭按钮和“解读记录”入口。主体顶部为报表主题输入区域,placeholder 为“输入报表主题”。下方说明“上传将获得解读以下内容”,并列出 AI 解读能力: - -1. 核心指标:快速提取重要业务数据。 -2. 异常提醒:定位需要重点关注的数据。 -3. 数据预览:分析环比、同比及变化趋势。 -4. 核心解读:用简单语言说明数据结论。 - -底部固定按钮“选择文件”。 - -业务规则: - -1. 报表主题必填,建议最大 50 字。 -2. 用户必须选择文件后才能开始分析。 -3. 支持 `xls`、`xlsx`、`csv`。 -4. 首版每次只支持上传一个文件。 -5. 已选择文件后可删除并重新选择。 -6. 点击“解读记录”进入表格解读记录页。 - -### 4.2 选择文件.png - -选择文件 - -功能说明: - -点击“选择文件”后从底部弹出选择报表文件弹窗。可选择: - -1. Excel 工作簿:支持 `XLS`、`XLSX` -2. CSV 数据文件:支持 `CSV` - -底部提供“取消”按钮。 - -业务规则: - -1. 文件类型必须由前后端共同校验,不能只按文件后缀判断。 -2. 文件大小建议首版限制为 10 MB,可由服务端配置。 -3. 表格行数、列数超过限制时返回明确错误。 -4. 文件上传失败时保留当前页面,提示重新上传。 -5. 文件中如存在多个 sheet,服务端默认读取第一个非空 sheet;后续可扩展 sheet 选择。 - -文件上传建议沿用 IM 文件接口: - -```http -POST /v0/file/u/ -X-MeetingIM-Auth: token -X-MeetingIM-APIKey: -multipart/form-data -``` - -表单字段建议: - -```text -id= -purpose=ai_table_source -file= -``` - -文件服务返回 `file_id` 后,再提交给工作台 AI 分析接口。 - -### 4.3 智能表格-文件上传成功.png - -智能表格-文件上传成功 xlsx - -### 4.4 智能表格-文件上传成功(1).png - -智能表格-文件上传成功 xls - -### 4.5 智能表格-文件上传成功(2).png - -智能表格-文件上传成功 csv - -功能说明: - -文件上传成功后,页面展示报表主题,例如“8月销售数据分析”;展示已选择报表卡片,包括文件类型图标、文件名、文件大小、上传成功状态和删除按钮。底部按钮变为“开始分析”。 - -业务规则: - -1. 上传成功后展示真实文件名和大小。 -2. 删除按钮用于移除当前文件,回到未选择文件状态。 -3. 点击“开始分析”创建 AI 表格解读任务。 -4. 同一个 `Idempotency-Key` 重复提交应返回同一个任务,避免重复扣费和重复生成。 - -创建分析任务接口: - -```http -POST /v1/workbench/ai/table-analyses -Idempotency-Key: -``` - -请求示例: - -```json -{ - "title": "8月销售数据分析", - "file_id": "DDd-DJUhRUA", - "file_name": "8月销售数据分析.xlsx", - "file_type": "xlsx" -} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_table_1", - "status": "processing", - "progress": 0, - "current_step": "read_table" - } -} -``` - -### 4.6 会议总结-识别中.png - -智能解读报告处理中 - -功能说明: - -表格分析过程中展示“智能解读报告”进度页。页面包含“正在智能解读”标题、说明“分析报表数据,请稍候...”、进度条、百分比、文件信息卡片和步骤时间轴。 - -步骤包括: - -1. 读取表格数据:已完成 -2. 识别核心指标:已完成 -3. 分析趋势变化:进行中 -4. 生成解读报告:等待中 - -底部提供“取消分析”按钮。 - -业务规则: - -1. 前端可轮询任务状态,也可后续升级为 WebSocket/SSE 推送。 -2. 进度值由服务端返回,不允许前端假进度超过服务端状态。 -3. 用户点击“取消分析”后,服务端将任务标记为 `canceled`;如果第三方 AI 调用不可中断,也应停止后续结果写入。 -4. 任务处理中退出页面,再进入记录页应能看到“生成中”状态。 -5. 任务超时后进入 `failed`,错误原因展示为“分析超时,请稍后重试”。 - -查询任务接口: - -```http -GET /v1/workbench/ai/tasks/{task_id} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_table_1", - "task_type": "smart_table", - "status": "processing", - "progress": 68, - "current_step": "analyze_trends", - "steps": [ - {"key": "read_table", "name": "读取表格数据", "status": "succeeded"}, - {"key": "extract_metrics", "name": "识别核心指标", "status": "succeeded"}, - {"key": "analyze_trends", "name": "分析趋势变化", "status": "processing"}, - {"key": "generate_report", "name": "生成解读报告", "status": "pending"} - ], - "source_file": { - "file_id": "DDd-DJUhRUA", - "file_name": "8月销售数据.xlsx", - "file_size": 3355443, - "file_type": "xlsx" - } - } -} -``` - -取消接口: - -```http -POST /v1/workbench/ai/tasks/{task_id}/cancel -Idempotency-Key: -``` - -### 4.7 报告解读.png - -报告解读 - -功能说明: - -表格分析完成后进入“报告解读”页面。页面展示: - -1. 报告标题:例如“8月销售数据”。 -2. 元信息:今天 10:30、Excel 报表。 -3. 核心指标卡片:销售额、订单量、退款率,包含指标值和涨跌幅。 -4. 数据预览表:月份、销售额、订单量、退款率等核心列。 -5. 异常提醒:例如“退款率连续两周上升,华南区域增幅明显”。 -6. 核心解读:自然语言总结业务结论和建议。 -7. 底部按钮:导出解读报告。 - -业务规则: - -1. 指标卡最多展示 3 个核心指标,超过时由 AI 选取最重要指标。 -2. 数据预览最多展示 10 行、6 列,完整文件仍通过原文件查看或导出查看。 -3. 异常提醒为空时隐藏该模块或展示“暂无明显异常”。 -4. 核心解读必须以用户能理解的业务语言输出,避免只重复表格数字。 -5. 导出报告应生成 Word 或 PDF,首版建议 Word。 -6. 导出文件应记录到文件系统,支持下载和在解读记录中查看。 - -结果结构示例: - -```json -{ - "title": "8月销售数据", - "source_type": "excel", - "metrics": [ - {"name": "销售额", "value": "286万", "change": "+18%", "trend": "up"}, - {"name": "订单量", "value": "12,680", "change": "+12%", "trend": "up"}, - {"name": "退款率", "value": "3.6%", "change": "+0.8%", "trend": "up"} - ], - "preview": { - "columns": ["月份", "销售额", "订单量", "退款率"], - "rows": [ - ["3月", "98万", "8,620", "+5.8%"], - ["4月", "198万", "9,450", "+8.6%"] - ] - }, - "warnings": [ - "退款率连续两周上升,华南区域增幅明显" - ], - "summary": "本月销售额比上月增长 18%,华东区域表现最好;订单量稳步提升,但退款率略有上升,建议重点关注华南区域的售后问题。" -} -``` - -导出接口: - -```http -POST /v1/workbench/ai/table-analyses/{record_id}/export -Idempotency-Key: -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "export_task_id": "aitask_export_1", - "status": "processing" - } -} -``` - -导出完成后返回: - -```json -{ - "code": 0, - "data": { - "file_id": "file_abc", - "file_name": "8月销售数据解读报告.docx", - "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "file_size": 123456 - } -} -``` - -### 4.8 总结记录.png - -表格解读记录 - -功能说明: - -表格解读记录页标题为“总结记录”,顶部提供搜索框。列表按日期分组展示已分析文件,包括文件类型图标、标题、原始文件类型、时间、状态“已完成”和右侧箭头。 - -业务规则: - -1. 支持按标题、文件名搜索。 -2. 点击记录进入报告解读详情。 -3. 记录状态包括 `已完成`、`解读中`、`失败`、`已取消`。 -4. 文件被删除或过期时,报告记录仍可查看文本结果;原文件下载入口置灰。 - -建议接口: - -```http -GET /v1/workbench/ai/records?type=smart_table&query=&cursor=&limit=20 -``` - -## 5. AI 美文 - -AI 美文用于优化用户输入文案,使内容更通顺、正式、完整,可嵌入日报、月报等表单,也可作为独立文本润色能力复用。 - -### 5.1 AI美文.png - -AI美文 - -功能说明: - -页面标题为“AI美文”。上方展示“原文”输入框,示例内容为“今天下班后和朋友去吃火锅,聊了很多最近的事,感觉很开心,希望以后能经常聚聚。”,右下角显示 `0/300`。下方展示“AI优化的文案”,输出优化后的文案。底部提供“重新生成”和“使用文案”按钮。 - -业务规则: - -1. 原文最大 300 字。 -2. 原文为空时不允许生成。 -3. 点击“重新生成”基于同一原文重新调用 AI,并生成新版本。 -4. 点击“使用文案”将 AI 优化文案回填到来源表单字段。 -5. 若从日报/月报进入,返回时应保留原表单其他字段。 -6. AI 输出不得自动覆盖原文,必须由用户确认。 - -建议接口: - -```http -POST /v1/workbench/ai/text/polish -Idempotency-Key: -``` - -请求示例: - -```json -{ - "source": "daily_report", - "field": "summary", - "text": "今天下班后和朋友去吃火锅,聊了很多最近的事,感觉很开心,希望以后能经常聚聚。", - "style": "formal" -} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_polish_1", - "status": "succeeded", - "result": { - "text": "下班后的快乐,是和朋友围坐在热气腾腾的火锅旁。聊聊近况,分享日常的小事,疲惫也在欢声笑语里慢慢散去。愿这样的相聚多一些,让平凡的日子多一点温暖。" - } - } -} -``` - -## 6. 日报 AI 接入 - -### 6.1 日报.png - -日报 - -功能说明: - -日报表单中增加“AI美文”按钮。页面包含日报起止时间、今日工作总结输入框和提交按钮。用户可在“今日工作总结”输入后点击“AI美文”进行文案优化。 - -业务规则: - -1. 日报原有提交逻辑保持不变。 -2. AI 美文只处理“今日工作总结”字段,不影响起止时间。 -3. 用户点击“使用文案”后,将优化文案回填到“今日工作总结”输入框。 -4. 若用户未输入今日工作总结,点击 AI 美文提示“请输入今日工作总结”。 -5. AI 优化后的日报仍需用户主动点击“提交”。 -6. 日报已有冲突规则时,AI 优化不绕过冲突校验。 - -建议和日报接口关系: - -```text -日报表单保存/提交仍使用原日报接口。 -AI 美文只提供文本优化,不直接提交日报。 -``` - -## 7. 月报 AI 接入 - -### 7.1 月报-发布中.png - -月报发布中 - -功能说明: - -月报发布页增加“AI美文”按钮。页面包含“本月工作总结”和“其他事项”两个输入框,均有 `0/1000` 字数限制,底部为提交按钮。 - -业务规则: - -1. `本月工作总结` 为必填字段,`其他事项` 可选。 -2. AI 美文默认优化当前聚焦或最近编辑的文本框;如无法判断,应弹出字段选择。 -3. 单字段最大 1000 字,但 AI 美文原型最大 300 字;首版建议: - - 日报/月报入口提交给 AI 时限制 300 字,超过时提示精简。 - - 或服务端支持 `max_chars=1000` 的办公文案优化模式。 -4. 使用 AI 文案后回填对应字段,不自动提交月报。 -5. 月报提交时仍执行原有必填、重复提交、月份唯一性等校验。 - -建议请求: - -```json -{ - "source": "monthly_report", - "field": "monthly_summary", - "text": "本月完成了工作台 AI 原型梳理和接口方案设计...", - "style": "formal" -} -``` - -## 8. 会议笔记 AI 总结 - -会议笔记 AI 总结基于会议基础信息、上传的会议笔记文件、人工笔记内容或语音识别文本生成会议总结。 - -### 8.1 会议笔记- AI总结.png - -会议笔记 AI总结 - -功能说明: - -会议详情页展示会议基础信息,包括会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方有页签“全部笔记(文件)”和“AI总结”。AI 总结内容包括: - -1. 会议结论 -2. 关键决策 -3. 后续安排表格 - -底部展示“由AI自动生成,如有不准确可手动编辑”,并提供“编辑”入口。 - -业务规则: - -1. 只有有会议笔记查看权限的用户可以查看 AI 总结。 -2. 会议记录员、发起人或具备笔记维护权限的用户可以触发 AI 总结生成和编辑。 -3. 普通参会人是否可查看 AI 总结由会议笔记权限决定。 -4. AI 总结需要绑定会议 ID。 -5. AI 总结来源应包括会议基础信息、会议笔记文件、人工补充内容和已有文件 OCR/文本解析结果。 -6. AI 总结结果必须允许人工编辑,编辑后保留编辑人和编辑时间。 -7. 如果会议没有任何笔记文件或文本来源,应提示“暂无可总结内容”。 - -建议接口: - -```http -POST /v1/workbench/meetings/{meeting_id}/ai-summary -Idempotency-Key: -``` - -请求示例: - -```json -{ - "source_file_ids": ["file_note_1", "file_note_2"], - "extra_text": "补充说明:预算方案需要下周前确认。" -} -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "task_id": "aitask_meeting_1", - "status": "processing" - } -} -``` - -查询会议 AI 总结: - -```http -GET /v1/workbench/meetings/{meeting_id}/ai-summary -``` - -响应示例: - -```json -{ - "code": 0, - "data": { - "summary_id": "msum_1", - "meeting_id": "mtg_1", - "status": "succeeded", - "sections": [ - { - "title": "会议结论", - "content": "本次会议围绕合作方案细节优化、时间节点调整及预算评估进行了讨论,并达成初步共识。" - }, - { - "title": "关键决策", - "items": [ - "整体合作方向确认,细节需要优化", - "第一阶段交付时间提前到 7月10日", - "市场部重新评估预算成本" - ] - } - ], - "actions": [ - {"item": "整理合作方案细节", "owner": "李明", "due_date": "2026-06-25"}, - {"item": "输出新的时间计划表", "owner": "张婷", "due_date": "2026-06-25"} - ], - "ai_generated": true, - "editable": true, - "updated_at": "2026-09-25T10:00:00Z" - } -} -``` - -编辑会议 AI 总结: - -```http -PUT /v1/workbench/meetings/{meeting_id}/ai-summary -Idempotency-Key: -``` - -业务规则: - -1. 编辑后 `ai_generated` 仍可保留为 true,但需要增加 `edited_by`、`edited_at`。 -2. 编辑内容需要版本控制,便于恢复和审计。 -3. 编辑不重新消耗 AI 调用额度。 - -## 9. AI 记录中心 - -### 9.1 记录类型 - -AI 记录包括: - -| 类型 | 来源 | 原型 | -| --- | --- | --- | -| `smart_document` | 智能文档生成 | 文档记录 | -| `smart_table` | 智能表格解读 | 总结记录 | -| `text_polish` | AI 美文 | 可选,不一定首版展示 | -| `meeting_summary` | 会议笔记 AI 总结 | 会议详情页 | - -### 9.2 通用记录字段 - -```json -{ - "record_id": "airec_1", - "type": "smart_table", - "title": "8月销售数据分析", - "status": "succeeded", - "display_status": "已完成", - "file_type": "xlsx", - "created_at": "2026-09-25T10:00:00Z", - "updated_at": "2026-09-25T10:01:00Z" -} -``` - -### 9.3 记录权限 - -1. 默认记录仅创建人可见。 -2. 会议 AI 总结按会议笔记权限可见。 -3. 后续如支持分享,需要显式分享给用户或群组,不默认企业内公开。 -4. 记录删除只删除工作台 AI 记录,不应直接删除原始文件,除非文件只被该记录引用且过期策略允许清理。 - -## 10. 服务端架构要求 - -### 10.1 模块划分 - -建议在 `im_workbench` 中增加 AI 业务模块: - -```text -internal/ai -internal/ai/provider -internal/ai/prompts -internal/ai/tasks -internal/httpapi/ai.go -internal/store/ai_mysql.go -``` - -职责: - -| 模块 | 职责 | -| --- | --- | -| HTTP API | 参数校验、鉴权、幂等、响应包装 | -| AI service | 编排任务、调用 provider、组织结果 | -| Provider | 对接具体大模型服务,封装重试、超时、错误 | -| Store | 保存任务、记录、结果、用量、导出文件 | -| Worker | 异步执行 AI 任务 | - -### 10.2 同步与异步 - -建议: - -1. AI 美文可以同步返回,但仍建议保存轻量任务记录。 -2. 智能文档可同步或短异步,首版建议异步,便于统一处理失败和重试。 -3. 智能表格必须异步,因为涉及文件解析和 AI 分析。 -4. 会议总结必须异步,因为涉及文件读取、OCR/文本抽取和总结生成。 - -### 10.3 队列 - -工作台服务当前已有 Kafka 配置,可用于 AI 任务异步执行;如果首版不引入 Kafka 消费,也可使用数据库任务表 + 后台 worker 轮询。 - -建议优先级: - -1. 简化首版:数据库任务表 + 本服务 worker。 -2. 后续增强:Kafka 投递 `workbench.ai.task.created`,worker 消费执行。 - -无论采用哪种方式,都需要: - -1. 幂等任务创建。 -2. 任务超时控制。 -3. 失败重试次数限制。 -4. 服务重启后可恢复 `pending/processing` 超时任务。 - -## 11. 数据库设计建议 - -### 11.1 AI 任务表 - -```sql -CREATE TABLE im_ai_tasks ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - task_id VARCHAR(64) NOT NULL, - task_type VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, - source_type VARCHAR(32) NOT NULL DEFAULT '', - source_id VARCHAR(128) NOT NULL DEFAULT '', - status VARCHAR(32) NOT NULL, - progress INT NOT NULL DEFAULT 0, - current_step VARCHAR(64) NOT NULL DEFAULT '', - input_json JSON NULL, - result_json JSON NULL, - error_code VARCHAR(64) NOT NULL DEFAULT '', - error_message VARCHAR(512) NOT NULL DEFAULT '', - idempotency_key VARCHAR(128) NOT NULL DEFAULT '', - started_at DATETIME NULL, - finished_at DATETIME NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - UNIQUE KEY uk_im_ai_tasks_task(task_id), - UNIQUE KEY uk_im_ai_tasks_idem(tenant_id,user_id,task_type,idempotency_key), - KEY idx_im_ai_tasks_user(tenant_id,user_id,task_type,created_at), - KEY idx_im_ai_tasks_status(status,updated_at) -); -``` - -### 11.2 AI 记录表 - -```sql -CREATE TABLE im_ai_records ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - record_id VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - record_type VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, - title VARCHAR(200) NOT NULL, - content_json JSON NULL, - source_file_id VARCHAR(128) NOT NULL DEFAULT '', - export_file_id VARCHAR(128) NOT NULL DEFAULT '', - status VARCHAR(32) NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME NULL, - UNIQUE KEY uk_im_ai_records_record(record_id), - KEY idx_im_ai_records_user(tenant_id,user_id,record_type,created_at), - KEY idx_im_ai_records_task(task_id) -); -``` - -### 11.3 AI 用量日志表 - -```sql -CREATE TABLE im_ai_usage_logs ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - task_id VARCHAR(64) NOT NULL, - provider VARCHAR(64) NOT NULL, - model VARCHAR(128) NOT NULL, - prompt_tokens INT NOT NULL DEFAULT 0, - completion_tokens INT NOT NULL DEFAULT 0, - total_tokens INT NOT NULL DEFAULT 0, - latency_ms INT NOT NULL DEFAULT 0, - status VARCHAR(32) NOT NULL, - error_code VARCHAR(128) NOT NULL DEFAULT '', - created_at DATETIME NOT NULL, - KEY idx_im_ai_usage_tenant(tenant_id,created_at), - KEY idx_im_ai_usage_user(tenant_id,user_id,created_at), - KEY idx_im_ai_usage_task(task_id) -); -``` - -### 11.4 会议 AI 总结表 - -```sql -CREATE TABLE im_meeting_ai_summaries ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - summary_id VARCHAR(64) NOT NULL, - meeting_id VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - content_json JSON NULL, - version INT NOT NULL DEFAULT 1, - ai_generated TINYINT(1) NOT NULL DEFAULT 1, - edited_by BIGINT NOT NULL DEFAULT 0, - edited_at DATETIME NULL, - created_by BIGINT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - UNIQUE KEY uk_im_meeting_ai_summary(summary_id), - UNIQUE KEY uk_im_meeting_ai_summary_meeting(tenant_id,meeting_id), - KEY idx_im_meeting_ai_summary_task(task_id) -); -``` - -## 12. AI Provider 配置 - -建议通过 manager 系统配置或 workbench 运行配置控制 AI Provider。 - -配置项建议: - -| 配置项 | 含义 | -| --- | --- | -| `ai.enabled` | 是否启用 AI | -| `ai.provider` | AI 服务商 | -| `ai.model.text` | 文本生成模型 | -| `ai.model.table` | 表格解读模型 | -| `ai.timeout_seconds` | 调用超时时间 | -| `ai.max_input_chars` | 文本最大输入长度 | -| `ai.table.max_file_size` | 表格最大文件大小 | -| `ai.table.max_rows` | 表格最大行数 | -| `ai.table.max_columns` | 表格最大列数 | -| `ai.daily_quota_per_user` | 单用户每日调用次数 | -| `ai.daily_quota_per_tenant` | 单租户每日调用次数 | - -要求: - -1. API Key 不允许下发到客户端。 -2. API Key 不允许写入 Git。 -3. Provider 调用失败需要脱敏记录错误。 -4. 支持按租户关闭 AI 能力。 - -## 13. 错误码建议 - -| HTTP | code | reason | 场景 | -| --- | --- | --- | --- | -| 400 | 30000 | `MALFORMED_REQUEST` | JSON 格式错误、缺少必填字段 | -| 400 | 30001 | `VALIDATION_FAILED` | 字数、文件类型、文件大小、行列数校验失败 | -| 401 | 30041 | `UNAUTHORIZED` | 未登录或 token 失效 | -| 403 | 30043 | `FORBIDDEN` | 无权限查看会议或记录 | -| 404 | 30044 | `RESOURCE_NOT_FOUND` | 任务、记录、文件不存在 | -| 409 | 30040 | `CONFLICT` | 幂等冲突或重复提交 | -| 429 | 30045 | `RATE_LIMITED` | AI 调用频控或额度耗尽 | -| 502 | 30100 | `AI_PROVIDER_FAILED` | AI 服务商调用失败 | -| 504 | 30101 | `AI_TIMEOUT` | AI 任务超时 | -| 422 | 30102 | `AI_CONTENT_REJECTED` | 输入或输出内容被安全策略拒绝 | -| 500 | 30103 | `AI_TASK_FAILED` | 任务执行失败 | - -错误响应示例: - -```json -{ - "code": 30001, - "reason": "VALIDATION_FAILED", - "message": "validation failed", - "details": { - "field": "source_text", - "max_length": 1000 - } -} -``` - -## 14. 前端交互要求 - -### 14.1 生成按钮状态 - -1. 输入为空时,生成按钮置灰或点击后提示必填。 -2. 生成中按钮进入 loading,不允许重复点击。 -3. 生成失败后恢复按钮,并展示失败原因。 -4. 使用文案/保存文档/导出报告成功后给出 Toast。 - -### 14.2 草稿保护 - -智能文档、日报、月报编辑中离开页面时,应保护用户输入: - -1. 返回前提示是否放弃编辑。 -2. 页面短暂切后台不应丢失输入。 -3. AI 生成失败不应清空原文。 - -### 14.3 记录刷新 - -1. 任务创建后立即进入处理页或结果页。 -2. 处理页轮询间隔建议 1~2 秒。 -3. 任务完成后停止轮询。 -4. 记录列表进入时刷新最新状态。 - -### 14.4 文件上传 - -1. 上传前校验文件类型。 -2. 上传中展示进度。 -3. 上传失败允许重试。 -4. 上传成功展示文件名、大小、类型和删除按钮。 - -## 15. 服务端验收标准 - -### 15.1 工作台入口 - -1. 首页能按配置展示智能文档和智能表格。 -2. 未启用 AI 时,AI 入口隐藏或置灰。 -3. 搜索应用可命中智能文档、智能表格、日报、会议笔记等应用。 -4. 无结果时返回空数组,前端展示“暂无匹配结果”。 - -### 15.2 智能文档 - -1. 输入 1~1000 字可创建生成任务。 -2. 超过 1000 字返回 `VALIDATION_FAILED`。 -3. 生成成功后返回标题和结构化正文。 -4. 重新生成会创建新版本或新任务。 -5. 保存文档后可在文档记录中查询。 - -### 15.3 智能表格 - -1. 支持上传 `xls`、`xlsx`、`csv`。 -2. 非法类型、超大文件、空文件返回明确错误。 -3. 创建分析任务后可查询进度。 -4. 分析完成后返回核心指标、数据预览、异常提醒和核心解读。 -5. 点击导出后生成可下载文件。 -6. 解读记录列表能查到已完成记录。 - -### 15.4 AI 美文 - -1. 原文为空不能生成。 -2. 文本超长返回明确错误。 -3. 生成结果不自动覆盖原文。 -4. 使用文案后能回填来源页面。 -5. 重新生成返回不同版本或刷新结果。 - -### 15.5 会议笔记 AI 总结 - -1. 有权限用户可生成会议 AI 总结。 -2. 无权限用户返回 `FORBIDDEN`。 -3. 无可总结内容时返回明确提示。 -4. 生成结果包含会议结论、关键决策和后续安排。 -5. 支持人工编辑,并保留编辑人和编辑时间。 - -## 16. 非功能要求 - -### 16.1 性能 - -1. AI 美文同步响应建议 10 秒内完成;超过 10 秒走异步或提示稍后查看。 -2. 智能文档生成建议 30 秒内完成。 -3. 智能表格解读建议 60 秒内完成,超时进入失败状态。 -4. 记录列表接口 P95 响应时间不超过 500 ms,不包含 AI 生成耗时。 - -### 16.2 稳定性 - -1. AI provider 异常不得影响 IM 聊天主链路。 -2. AI 任务失败不得造成文件丢失或日报/月报草稿丢失。 -3. 服务重启后,未完成任务可恢复或标记失败。 -4. 对 provider 做熔断和限流,避免拖垮 workbench 服务。 - -### 16.3 安全 - -1. AI 输入、输出和上传文件均按租户隔离。 -2. 不在日志中打印用户原文、AI 输出、文件内容、provider key。 -3. 导出文件下载必须鉴权。 -4. 上传文件必须做大小、MIME、扩展名和内容解析校验。 -5. 会议总结必须校验会议权限。 - -### 16.4 审计 - -需要记录: - -1. 谁发起 AI 任务。 -2. 发起时间、完成时间、耗时。 -3. 任务类型和来源。 -4. provider、model、token 用量。 -5. 成功或失败原因。 -6. 导出文件和下载行为。 - -## 17. 首版范围与后续扩展 - -### 17.1 首版必须实现 - -1. 工作台展示智能文档和智能表格入口。 -2. 工作台应用搜索。 -3. 智能文档生成、保存、记录列表。 -4. 智能表格上传、分析、进度、报告解读、记录列表。 -5. AI 美文用于日报和月报。 -6. 会议笔记 AI 总结生成和查看。 -7. 统一 AI 任务表、记录表、用量日志。 -8. 租户级 AI 开关和基础额度限制。 - -### 17.2 可后续扩展 - -1. AI 文档导出 Word/PDF。 -2. AI 表格多 sheet 选择。 -3. 解读报告分享给同事或群聊。 -4. 会议语音转写后自动总结。 -5. 按部门、角色配置 AI 使用额度。 -6. 管理后台查看 AI 使用统计。 -7. Prompt 模板后台配置。 -8. AI 结果人工反馈,用于优化提示词。 - diff --git a/docs/sql/IM-工作台AI建表SQL.sql b/docs/sql/IM-工作台AI建表SQL.sql deleted file mode 100644 index da7cec1..0000000 --- a/docs/sql/IM-工作台AI建表SQL.sql +++ /dev/null @@ -1,230 +0,0 @@ --- IM 工作台 AI 建表 SQL --- Run on the shared meeting_im database. - -CREATE TABLE IF NOT EXISTS wb_ai_capability_config ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - tenant_code VARCHAR(64) NOT NULL DEFAULT '', - feature VARCHAR(64) NOT NULL, - enabled TINYINT(1) NOT NULL DEFAULT 0, - model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', - daily_limit INT NOT NULL DEFAULT 0, - concurrency_limit INT NOT NULL DEFAULT 1, - max_input_chars INT NOT NULL DEFAULT 0, - max_file_size_mb INT NOT NULL DEFAULT 0, - config_json JSON NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_tenant_feature (tenant_id, feature) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_task ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - task_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - feature VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - progress INT NOT NULL DEFAULT 0, - source_type VARCHAR(32) NOT NULL DEFAULT '', - source_ref_id VARCHAR(128) NOT NULL DEFAULT '', - input_hash VARCHAR(128) NOT NULL DEFAULT '', - input_snapshot JSON NULL, - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - result_ref_id VARCHAR(64) NOT NULL DEFAULT '', - result_json JSON NULL, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - error_message VARCHAR(255) NOT NULL DEFAULT '', - retry_count INT NOT NULL DEFAULT 0, - started_at DATETIME(3) NULL, - finished_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_task_id (task_id), - KEY idx_user_feature_time (tenant_id, user_id, feature, created_at), - KEY idx_status_time (status, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_task_step ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - task_id VARCHAR(64) NOT NULL, - step VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - status VARCHAR(32) NOT NULL, - progress INT NOT NULL DEFAULT 0, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - started_at DATETIME(3) NULL, - finished_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_task_step (task_id, step) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_record ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - record_type VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - summary VARCHAR(512) NOT NULL DEFAULT '', - status VARCHAR(32) NOT NULL, - detail_id VARCHAR(64) NOT NULL DEFAULT '', - task_id VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - deleted_at DATETIME(3) NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id), - KEY idx_user_type_time (tenant_id, user_id, record_type, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_document ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - document_type VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - content_markdown MEDIUMTEXT NOT NULL, - content_plain MEDIUMTEXT NULL, - sections_json JSON NULL, - source_excerpt VARCHAR(1024) NOT NULL DEFAULT '', - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id), - KEY idx_tenant_user_time (tenant_id, user_id, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_table_analysis ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - file_id VARCHAR(128) NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_type VARCHAR(16) NOT NULL, - file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, - topic VARCHAR(128) NOT NULL, - metrics_json JSON NULL, - preview_json JSON NULL, - alerts_json JSON NULL, - interpretation MEDIUMTEXT NULL, - export_file_id VARCHAR(128) NOT NULL DEFAULT '', - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_record_id (record_id), - KEY idx_file_id (tenant_id, file_id), - KEY idx_tenant_user_time (tenant_id, user_id, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_meeting_summary ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - summary_id VARCHAR(64) NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - meeting_id VARCHAR(128) NOT NULL, - user_id VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - conclusions_json JSON NULL, - decisions_json JSON NULL, - action_items_json JSON NULL, - source_file_ids_json JSON NULL, - prompt_version VARCHAR(64) NOT NULL DEFAULT '', - model VARCHAR(64) NOT NULL DEFAULT '', - edited_by VARCHAR(64) NOT NULL DEFAULT '', - edited_at DATETIME(3) NULL, - version INT NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_summary_id (summary_id), - UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id), - KEY idx_user_time (tenant_id, user_id, updated_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_usage_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - tenant_code VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - feature VARCHAR(64) NOT NULL, - task_id VARCHAR(64) NOT NULL DEFAULT '', - provider VARCHAR(32) NOT NULL, - model VARCHAR(64) NOT NULL, - provider_request_id VARCHAR(128) NOT NULL DEFAULT '', - prompt_tokens INT NOT NULL DEFAULT 0, - completion_tokens INT NOT NULL DEFAULT 0, - total_tokens INT NOT NULL DEFAULT 0, - latency_ms INT NOT NULL DEFAULT 0, - status VARCHAR(32) NOT NULL, - error_reason VARCHAR(64) NOT NULL DEFAULT '', - input_hash VARCHAR(128) NOT NULL DEFAULT '', - output_hash VARCHAR(128) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - KEY idx_user_feature_day (tenant_id, user_id, feature, created_at), - KEY idx_provider_time (provider, model, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_idempotency_record ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tenant_id BIGINT UNSIGNED NOT NULL, - user_id VARCHAR(64) NOT NULL, - idempotency_key VARCHAR(128) NOT NULL, - request_hash VARCHAR(128) NOT NULL, - response_code INT NOT NULL DEFAULT 0, - response_body JSON NULL, - status VARCHAR(32) NOT NULL, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -CREATE TABLE IF NOT EXISTS wb_ai_prompt_template ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - template_key VARCHAR(64) NOT NULL, - version VARCHAR(64) NOT NULL, - provider VARCHAR(32) NOT NULL DEFAULT 'deepseek', - model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', - system_prompt MEDIUMTEXT NOT NULL, - user_prompt_template MEDIUMTEXT NOT NULL, - response_schema JSON NULL, - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_template_version (template_key, version), - KEY idx_template_enabled (template_key, enabled) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - -INSERT INTO wb_ai_capability_config - (tenant_id, tenant_code, feature, enabled, model, daily_limit, concurrency_limit, max_input_chars, max_file_size_mb, config_json, created_at, updated_at) -VALUES - (0, '', 'smart_document', 0, 'deepseek-flash', 20, 1, 1000, 0, JSON_OBJECT('entry_title', '智能文档'), NOW(3), NOW(3)), - (0, '', 'smart_table', 0, 'deepseek-v4-pro', 10, 1, 100, 20, JSON_OBJECT('entry_title', '智能表格', 'allowed_file_types', JSON_ARRAY('xlsx', 'xls', 'csv')), NOW(3), NOW(3)), - (0, '', 'text_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), - (0, '', 'daily_report_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), - (0, '', 'monthly_report_polish', 0, 'deepseek-flash', 30, 3, 1000, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), - (0, '', 'meeting_summary', 0, 'deepseek-v4-pro', 10, 1, 0, 0, JSON_OBJECT('entry_title', 'AI总结'), NOW(3), NOW(3)) -ON DUPLICATE KEY UPDATE - model = VALUES(model), - daily_limit = VALUES(daily_limit), - concurrency_limit = VALUES(concurrency_limit), - max_input_chars = VALUES(max_input_chars), - max_file_size_mb = VALUES(max_file_size_mb), - config_json = VALUES(config_json), - updated_at = NOW(3); diff --git a/docs/工作台与Manager内部配置运维说明.md b/docs/工作台与Manager内部配置运维说明.md deleted file mode 100644 index db2066b..0000000 --- a/docs/工作台与Manager内部配置运维说明.md +++ /dev/null @@ -1,442 +0,0 @@ -# 工作台与 Manager 内部配置运维说明 - -更新时间:2026-09-17 - -## 1. 背景 - -`im_workbench` 需要从 `im_manager` 读取系统配置,例如: - -- `im.uid_encryption_key` - -读取链路如下: - -```text -im_workbench - -> GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key - -> im_manager 校验 HMAC 签名 - -> 返回系统配置 -``` - -这个接口是服务间内部接口,不是普通前端接口。调用方必须带 HMAC 请求头: - -```http -X-Service-Name: im-workbench -X-Timestamp: -X-Nonce: -X-Signature: -``` - -签名使用的密钥来自 `im_manager` 测试环境 Secret 中的: - -```text -manager-system / im-manager-secrets / IM_CONFIG_API_SECRET -``` - -注意:不要把真实密钥写进 Git 文档或代码。 - -## 2. 为什么 .env 不生效 - -测试环境运行在 Kubernetes Pod 中,服务启动时不会自动读取仓库里的 `.env` 文件。 - -当前代码和启动方式是: - -- `im_workbench` 只读取进程环境变量和 `IM_WORKBENCH_CONFIG` 指向的 JSON 配置文件。 -- `im_manager` 的 entrypoint 使用 Pod 环境变量做 `envsubst` 生成 `config.yaml`。 -- 两个服务都没有 `godotenv.Load()`、`source .env` 或 docker compose `env_file` 逻辑。 - -所以测试环境真正生效的是: - -```text -Kubernetes Secret / Deployment 环境变量 -``` - -不是仓库中的 `.env`。 - -## 3. 当前 Kubernetes 配置关系 - -### im_workbench - -命名空间: - -```text -workbench-system -``` - -Deployment: - -```text -im-workbench -``` - -当前 Pod 里只显式注入: - -```text -IM_WORKBENCH_CONFIG=/etc/im-workbench/workbench.json -``` - -`/etc/im-workbench/workbench.json` 来自: - -```text -workbench-system / im-workbench-secrets / workbench.json -``` - -因此 workbench 的运行时配置要改这个 Secret 里的 `workbench.json`,或在 Deployment 里显式加环境变量。 - -### im_manager - -命名空间: - -```text -manager-system -``` - -Deployment: - -```text -im-manager -``` - -Secret: - -```text -manager-system / im-manager-secrets -``` - -其中和内部配置接口相关的 key: - -```text -IM_CONFIG_API_SERVICE_NAME -IM_CONFIG_API_SECRET -``` - -## 4. 必须配置什么 - -### 4.1 im_manager 需要允许 im_workbench 调配置接口 - -`im_manager` 的 `IM_CONFIG_API_SERVICE_NAME` 需要支持两个调用方: - -```text -im-server,im-workbench -``` - -原因: - -- `im_server` 也需要读取 manager 配置。 -- `im_workbench` 也需要读取 manager 配置。 -- 如果只配置 `im-workbench`,会影响 `im_server`。 -- 如果只配置 `im-server`,`im_workbench` 用正确 service name 调用会 401。 - -目标配置: - -```text -IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench -``` - -`IM_CONFIG_API_SECRET` 保持现有值,不要改成空,也不要泄露到日志或文档。 - -### 4.2 im_workbench 需要配置 manager 连接信息 - -`workbench.json` 中需要有: - -```json -{ - "manager": { - "base_url": "https://admin.118im.com", - "service_name": "im-workbench", - "secret": "<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET>", - "uid_key_group": "im", - "uid_key_keys": [ - "uid_encryption_key", - "UID_ENCRYPTION_KEY", - "uid_key" - ], - "timeout_ms": 3000 - } -} -``` - -也可以不用改 JSON,改成 Deployment 环境变量: - -```text -IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com -IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench -IM_WORKBENCH_MANAGER_SECRET=<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET> -``` - -建议优先统一放到 `workbench.json`,因为当前 workbench 已经通过 Secret 挂载 JSON 配置。 - -## 5. 推荐操作流程 - -### 步骤 1:更新 manager Secret - -将: - -```text -manager-system / im-manager-secrets / IM_CONFIG_API_SERVICE_NAME -``` - -改为: - -```text -im-server,im-workbench -``` - -`IM_CONFIG_API_SECRET` 保持现有值。 - -### 步骤 2:更新 workbench Secret - -从: - -```text -manager-system / im-manager-secrets / IM_CONFIG_API_SECRET -``` - -取值,写入: - -```text -workbench-system / im-workbench-secrets / workbench.json -``` - -目标字段: - -```json -{ - "manager": { - "base_url": "https://admin.118im.com", - "service_name": "im-workbench", - "secret": "" - } -} -``` - -### 步骤 3:重启服务 - -更新 Secret 后需要重启 Pod,确保进程重新读取配置。 - -建议顺序: - -```text -1. 重启 im-manager -2. 重启 im-workbench -``` - -原因: - -- 先让 manager 支持 `im-workbench` service name。 -- 再让 workbench 使用 `im-workbench` service name 去调用 manager。 - -## 6. 验证方法 - -### 6.1 验证 manager 是否接受 im-workbench - -用 `IM_CONFIG_API_SECRET` 对请求签名,请求: - -```text -GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key -X-Service-Name: im-workbench -``` - -期望结果: - -```json -{ - "code": 0, - "data": { - "values": { - "uid_encryption_key": "..." - } - } -} -``` - -如果返回 401,重点检查: - -- `IM_CONFIG_API_SERVICE_NAME` 是否包含 `im-workbench` -- 请求里的 `X-Service-Name` 是否是 `im-workbench` -- `IM_CONFIG_API_SECRET` 是否和 manager 当前 Secret 一致 -- 签名文本是否为: - -```text -METHOD -RequestURI -BodySHA256 -Timestamp -Nonce -``` - -### 6.2 验证 workbench 是否加载到 manager 配置 - -进入 workbench Pod 看环境和配置摘要,注意不要打印真实 secret: - -```bash -kubectl -n workbench-system exec -- sh -c 'env | grep IM_WORKBENCH_MANAGER' -``` - -或查看挂载配置: - -```bash -kubectl -n workbench-system exec -- sh -c 'cat /etc/im-workbench/workbench.json' -``` - -检查: - -```text -manager.base_url = https://admin.118im.com -manager.service_name = im-workbench -manager.secret 非空 -``` - -### 6.3 验证业务接口 - -从 IM 登录获取 token 后,请求 workbench 接口: - -```http -GET https://api.118im.com/v1/workbench -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: -``` - -期望: - -```json -{ - "code": 0 -} -``` - -再创建会议,确认 user id 编解码和展示名正常: - -```http -POST https://api.118im.com/v1/workbench/meetings -Idempotency-Key: -Authorization: Bearer -X-MeetingIM-APIKey: -X-IM-Schema: 1 -X-Tenant-Code: -Content-Type: application/json -``` - -请求体示例: - -```json -{ - "title": "运维验证会议", - "start_at": "2026-09-18T08:00:00Z", - "end_at": "2026-09-18T09:00:00Z", - "location": "", - "recorder_user_id": "usr...", - "attendee_user_ids": ["usr..."], - "reminder_minutes": [] -} -``` - -期望: - -```json -{ - "code": 0, - "data": { - "state": "scheduled", - "organizer": { - "user_id": "usr...", - "display_name": "..." - }, - "participant_names": ["..."] - } -} -``` - -## 7. 常见问题 - -### 7.1 为什么不能从 manager 读取 manager secret - -因为 `IM_CONFIG_API_SECRET` 是调用 manager 配置接口的 HMAC 引导凭证。 - -如果要读取 manager 配置接口,必须先有这个 secret 才能签名。 -所以它不能从同一个 manager 配置接口中获取,只能来自部署环境,例如 Kubernetes Secret。 - -### 7.2 为什么 service name 不是 im-server - -`X-Service-Name` 表示调用方服务名。 - -- `im_server` 调 manager 时,应使用 `im-server` -- `im_workbench` 调 manager 时,应使用 `im-workbench` - -manager 侧应该允许多个合法调用方: - -```text -im-server,im-workbench -``` - -### 7.3 为什么配置了 .env 还是没用 - -因为 Kubernetes 不会自动读取仓库 `.env`。 - -`.env` 只有在以下情况才会生效: - -- 程序代码主动 `godotenv.Load()` -- 启动脚本 `source .env` -- docker compose 配了 `env_file` -- CI/CD 将 `.env` 内容写进 Deployment 或 Secret - -当前测试环境都不是这些方式。 - -### 7.4 只改 workbench 会怎样 - -如果只把 workbench 改成: - -```text -X-Service-Name: im-workbench -``` - -但 manager 仍然只允许: - -```text -IM_CONFIG_API_SERVICE_NAME=im-server -``` - -那么 workbench 调 manager 会返回 401。 - -所以需要 manager 和 workbench 一起配置。 - -## 8. 最终配置清单 - -manager: - -```text -Namespace: manager-system -Secret: im-manager-secrets - -IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench -IM_CONFIG_API_SECRET=<保持现有值> -``` - -workbench: - -```text -Namespace: workbench-system -Secret: im-workbench-secrets -Key: workbench.json -``` - -`workbench.json`: - -```json -{ - "manager": { - "base_url": "https://admin.118im.com", - "service_name": "im-workbench", - "secret": "", - "uid_key_group": "im", - "uid_key_keys": [ - "uid_encryption_key", - "UID_ENCRYPTION_KEY", - "uid_key" - ], - "timeout_ms": 3000 - } -} -``` - diff --git a/docs/工作台迁移对接总结文档.md b/docs/工作台迁移对接总结文档.md deleted file mode 100644 index b952b09..0000000 --- a/docs/工作台迁移对接总结文档.md +++ /dev/null @@ -1,351 +0,0 @@ -# 工作台迁移对接总结文档 - -> 更新时间:2026-09-14。本文用于后续开启新的 `im_workbench` 工作树时快速接手,覆盖当前迁移边界、接口、鉴权、中间件配置、Kafka 事件、数据库现状和验收步骤。 - -## 1. 当前结论 - -工作台已从 `im_server` 拆到独立仓库 `im_workbench`,当前阶段只拆服务和接口边界,数据库暂不迁移。工作台接口统一走 `/v1/workbench/*`,由网关分流到 `im_workbench`;登录仍由 `im_server` 负责,登录成功后把 Token 会话写入 Redis,`im_workbench` 从 Redis 校验登录态。 - -当前已完成: - -| 项目 | 状态 | -|---|---| -| 独立 Go 工程 | 已完成,仓库:`/Users/youshunlai/go_workspace/im_workbench` | -| 独立启动 | 已完成,默认监听 `:6061` | -| 新路由 `/v1/workbench/*` | 已完成 | -| Redis Token 鉴权 | 已完成 | -| 工作台 Kafka 事件生产 | 已完成 | -| 测试环境中间件配置 | 已更新 | -| 数据库迁移 | 暂不迁移,仍使用租户库 `tinode` | -| `im_server` 旧工作台代码 | 暂保留,等新服务稳定后再删 | - -最近关键提交: - -| 仓库 | Commit | 说明 | -|---|---|---| -| `im_workbench` | `8b5c5e3` | 兼容当前工作台表结构 | -| `im_workbench` | `c1c1156` | 更新新测试环境中间件配置,修复 Kafka 发布 | - -## 2. 服务边界 - -### 2.1 im_workbench 负责 - -| 模块 | 接口范围 | -|---|---| -| 工作台首页 | `GET /v1/workbench` | -| 组织架构 | `/v1/workbench/organization/*` | -| 考勤 | `/v1/workbench/attendance/*` | -| 会议 | `/v1/workbench/meetings*` | -| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | -| 日报 | `/v1/workbench/reports/daily*` | -| 月报 | `/v1/workbench/reports/monthly*` | -| 总经理信箱 | `/v1/workbench/executive-mailbox/*` | -| 工作台业务事件 | 生产 Kafka 事件到 `im.workbench.event` | - -### 2.2 im_server 继续负责 - -| 能力 | 说明 | -|---|---| -| 登录、注册、Token 签发 | C 端登录仍走 `im_server` | -| Redis 登录态写入 | 登录成功后写 `im:session:token:{sha256(token)}` | -| 文件上传 | 暂由 `/v0/file/u/` 处理,工作台只保存 `file_id` | -| WebSocket 实时下发 | 工作台事件由 `im_server` 消费后转 C 端通知 | -| IM 消息、群、好友、在线状态 | 仍全部留在 `im_server` | - -## 3. 网关路由 - -客户端对外仍访问统一域名,由 Nginx/API Gateway 按路径分流: - -```text -/v0/channels -> im_server -/v0/file/u/ -> im_server -/v1/im/* -> im_server -/v1/workbench/* -> im_workbench -``` - -测试环境域名: - -| 域名 | 用途 | -|---|---| -| `https://api.118im.com` | 后端 API,包含 `/v1/workbench/*` | -| `https://api-test.118im.com` | 测试 API 备用域名 | -| `https://web.118im.com` | Web 前端同源代理 | - -注意:不要再让 `im_server` 代理工作台新接口。新接口应直接进入 `im_workbench`。 - -## 4. 请求鉴权 - -工作台接口和 IM 产品 HTTP 保持同一套公共 Header: - -| Header | 必填 | 说明 | -|---|---|---| -| `Authorization` | 是 | `Bearer {token}` | -| `X-MeetingIM-APIKey` | 是 | 客户端 API Key | -| `X-Tenant-Code` | 是 | 当前租户编码,例如 `default` | -| `X-IM-Schema` | 是 | 当前固定 `1` | -| `X-Request-ID` | 建议必填 | 请求链路 ID | -| `Idempotency-Key` | 写接口必填 | 写操作幂等键 | - -登录态 Redis Key: - -```text -im:session:token:{sha256(token)} -``` - -Redis Value: - -```json -{ - "user_id": 1572927143048908800, - "tenant_id": 1, - "tenant_code": "default", - "device_id": "ios-device-1", - "auth_level": "auth", - "state": "active", - "issued_at": "2026-09-14T10:00:00Z", - "expires_at": "2026-09-14T12:00:00Z" -} -``` - -鉴权流程: - -1. `im_workbench` 校验 `X-MeetingIM-APIKey` 非空或命中配置白名单。 -2. 从 `Authorization` 读取 Bearer Token。 -3. 对 Token 做 `sha256`,查 Redis 登录态。 -4. 校验 `state=active`、未过期。 -5. 校验 Header 的 `X-Tenant-Code` 与登录态 `tenant_code` 一致。 - -测试环境当前可用 API Key 以部署文档为准,最近验证可用的是新测试环境文档中的 `TINODE_API_KEY`。 - -## 5. 测试环境配置 - -配置文件: - -```text -/Users/youshunlai/go_workspace/im_workbench/configs/workbench.test.json -``` - -当前关键配置: - -| 配置 | 值 | -|---|---| -| 服务监听 | `:6061` | -| MySQL | `mysql.118im.com:30306` | -| MySQL 业务库 | `tinode` | -| Redis 数据端口 | `redis.118im.com:30637` | -| Redis Sentinel | `redis.118im.com:30638` | -| Kafka Bootstrap | `kafka.118im.com:30835` | -| Kafka 协议 | `PLAINTEXT` | -| Kafka Topic | `im.workbench.event` | - -说明: - -1. Kafka 测试环境是明文直连,不配置 SASL/TLS。 -2. `im.workbench.event` 已在新 Kafka 集群创建。 -3. Redis 数据 NodePort 可能落到主节点或副本。服务正常读登录态没问题;如果本地要写临时测试 session,可能遇到 `READONLY`,需要通过真正主库入口或先走真实登录链路。 -4. MySQL、Redis 密码不写入本文,按 `im_server/docs/problems/新测试环境部署与中间件连接信息.md` 或部署 Secret 获取。 - -## 6. 本地启动 - -本地开发配置: - -```bash -IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench -``` - -连接测试环境配置: - -```bash -IM_WORKBENCH_CONFIG=configs/workbench.test.json go run ./cmd/workbench -``` - -如果只做本地接口调试,可以临时打开开发 Header 鉴权: - -```bash -IM_WORKBENCH_CONFIG=configs/workbench.test.json \ -IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true \ -go run ./cmd/workbench -``` - -开发 Header: - -```text -X-User-ID: 1572927143048908800 -X-Tenant-ID: 1 -X-Tenant-Code: default -X-MeetingIM-APIKey: x -X-IM-Schema: 1 -X-Request-ID: {uuid} -``` - -注意:`IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true` 只能本地调试使用,测试/生产部署不要开启。 - -## 7. Kafka 事件 - -工作台写操作统一投递: - -```text -Topic: im.workbench.event -``` - -事件体: - -```json -{ - "event_id": "wbe_xxx", - "trace_id": "request-id", - "tenant_id": 1, - "tenant_code": "default", - "actor_user_id": 42, - "module": "meeting", - "action": "meeting.created", - "resource_id": "mtg_xxx", - "target_user_ids": [42, 43], - "title": "会议通知", - "summary": "项目周会", - "occurred_at": "2026-09-14T06:00:00Z", - "data": {} -} -``` - -当前事件范围: - -| 模块 | Action | 触发场景 | -|---|---|---| -| 考勤 | `attendance.record.updated` | 打卡成功 | -| 考勤 | `attendance.appeal.created` | 提交考勤申诉 | -| 会议 | `meeting.created` | 创建会议 | -| 会议 | `meeting.updated` | 修改会议 | -| 会议 | `meeting.canceled` | 取消会议 | -| 会议 | `meeting.response.updated` | 更新参会回执 | -| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 | -| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 | -| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 | -| 日报 | `daily_report.draft.created` | 创建日报草稿 | -| 日报 | `daily_report.draft.saved` | 保存日报草稿 | -| 日报 | `daily_report.submitted` | 提交日报 | -| 日报 | `daily_report.reminded` | 日报提醒 | -| 月报 | `monthly_report.draft.created` | 创建月报草稿 | -| 月报 | `monthly_report.draft.saved` | 保存月报草稿 | -| 月报 | `monthly_report.submitted` | 提交月报 | -| 月报 | `monthly_report.reopened` | 重开月报 | -| 信箱 | `mailbox.submitted` | 提交总经理信箱 | - -已修复问题:`kafka.Writer` 设置了默认 Topic 后,单条 `kafka.Message` 不再重复设置 Topic,否则 `kafka-go` 会报 `Topic must not be specified for both Writer and Message`。 - -## 8. 数据库现状 - -当前数据库不迁移,`im_workbench` 仍连接租户库 `tinode`。 - -主要表: - -| 表 | 用途 | -|---|---| -| `im_workbench_apps` | 工作台模块 | -| `im_org_departments` | 组织部门 | -| `im_org_members` | 组织成员 | -| `im_org_visibility_overrides` | 组织可见范围 | -| `im_attendance_rules` | 考勤规则 | -| `im_attendance_shifts` | 考勤班次 | -| `im_attendance_records` | 考勤记录 | -| `im_attendance_checkins` | 打卡流水 | -| `im_attendance_appeals` | 考勤申诉 | -| `im_meetings` | 会议 | -| `im_meeting_participants` | 会议参与人 | -| `im_meeting_note_files` | 会议笔记文件引用 | -| `im_daily_reports` | 日报 | -| `im_monthly_reports` | 月报 | -| `im_mailbox_config` | 总经理信箱配置 | -| `im_mailbox_submissions` | 总经理信箱提交 | - -已兼容当前表结构: - -| 表 | 当前结构注意点 | 代码处理 | -|---|---|---| -| `im_org_members` | 成员状态字段是 `employment_state` | 查询时返回为 `status` | -| `im_attendance_rules` | 没有 `appeal_deadline` 字段 | 查询返回 `NULL AS appeal_deadline` | -| `im_mailbox_config` | 没有 `version` 字段 | 用 `notice_updated_at` 派生版本 | - -服务启动不自动建库建表,不执行 DDL。所有建表、改字段、初始化数据都走人工 SQL 审核和发布。 - -## 9. 已验证接口 - -使用新测试环境配置启动本地 `im_workbench`,并开启本地开发 Header 鉴权后,已验证: - -| 接口 | 结果 | -|---|---| -| `GET /v1/workbench` | 200 | -| `GET /v1/workbench/organization/departments` | 200 | -| `GET /v1/workbench/attendance/config` | 200 | -| `GET /v1/workbench/executive-mailbox/config` | 200 | -| `POST /v1/workbench/executive-mailbox/submissions` | 200,Kafka 发布成功 | - -同时执行: - -```bash -go test ./... -``` - -结果通过。 - -## 10. 新工作树接手步骤 - -1. 拉取 `im_workbench` 的 `dev` 分支。 -2. 确认包含提交 `c1c1156` 或更新的提交。 -3. 查看 `configs/workbench.test.json`,确认中间件地址是新测试环境域名,不是旧 VM `64.118.137.182`。 -4. 执行 `go test ./...`。 -5. 本地启动: - -```bash -IM_WORKBENCH_CONFIG=configs/workbench.test.json \ -IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH=true \ -go run ./cmd/workbench -``` - -6. 用开发 Header 调 `GET /v1/workbench`、组织、考勤、信箱配置。 -7. 调一个写接口,例如信箱提交,确认返回 200,并在数据库清理测试数据。 -8. 关闭开发 Header 鉴权,走真实登录 Token 验证网关链路。 -9. 验证公网 `/v1/workbench/*` 路由已经进入 `im_workbench`,并确认网关透传 `Authorization`、`X-MeetingIM-APIKey`、`X-Tenant-Code`、`X-IM-Schema`、`X-Request-ID`。 - -## 11. 待后续处理 - -| 事项 | 说明 | -|---|---| -| 真实登录态联调 | 需要通过 `im_server` 登录后生成 Redis session,再从公网 `/v1/workbench/*` 验证 | -| 网关 Header 透传 | 必须确认 `Authorization` 没被 Nginx/API Gateway 清掉 | -| Redis 写入口 | 数据 NodePort 可能落副本,本地造 session 可能遇到 `READONLY` | -| im_server 消费工作台事件 | `im_server` 消费 `im.workbench.event` 后转系统通知/WS 下发需要继续联调 | -| 工作台数据库迁移 | 现阶段不迁,接口稳定后单独做工作台库迁移 | -| 删除 im_server 旧工作台代码 | 等新服务、网关、前端、通知链路全部稳定后再删 | - -## 12. 常见问题 - -### 12.1 `/v1/workbench` 返回 `AUTH_REQUIRED` - -优先检查: - -1. 是否带 `Authorization: Bearer {token}`。 -2. Redis 是否有 `im:session:token:{sha256(token)}`。 -3. session 是否 `state=active` 且未过期。 -4. `X-Tenant-Code` 是否和 session 中的 `tenant_code` 一致。 -5. 网关是否透传 `Authorization`。 -6. `im_workbench` 是否连到了和 `im_server` 相同的 Redis。 - -### 12.2 写接口返回 `workbench event publish failed` - -优先检查: - -1. Kafka broker 是否为 `kafka.118im.com:30835`。 -2. Kafka 是否为 `PLAINTEXT`,不要配置 SASL。 -3. Topic `im.workbench.event` 是否存在。 -4. 服务代码是否已经包含 Kafka 双 Topic 修复。 - -### 12.3 组织或考勤接口 500 - -优先检查当前表结构是否和代码兼容: - -1. `im_org_members` 使用 `employment_state`,不是 `status`。 -2. `im_attendance_rules` 当前没有 `appeal_deadline`。 -3. `im_mailbox_config` 当前没有 `version`。 - -这些兼容已在当前代码中处理;如果新工作树仍 500,先确认是否拉到最新提交。 diff --git a/docs/注册登录对接文档.md b/docs/注册登录对接文档.md new file mode 100644 index 0000000..6d7864a --- /dev/null +++ b/docs/注册登录对接文档.md @@ -0,0 +1,101 @@ +# 注册登录对接文档 + +接口公共前缀为 `/v1`,旧的应用名路径前缀不再提供兼容路由。 + +## 1 数据模型 + +账户以 `olons_admin.hg_app_member` 为主档: + +- 手机号写入 `mobile_cipher`、`mobile_hash` 和 `mobile_mask`。 +- 登录密码使用 bcrypt 写入 `password_hash`。 +- 推荐码通过上级会员的 `invite_code` 建立 `parent_id`、`level` 和 `tree`。 +- 注册事务同时创建 `hg_app_member_profile` 和 CNY `hg_wallet_account`。 + +## 2 公共 Header + +```http +X-API-Key: +X-Request-ID: +X-Device-ID: +``` + +## 3 注册 + +```http +POST /v1/auth/register +Content-Type: application/json +``` + +```json +{ + "username": "alice01", + "phone": "13800138000", + "password": "Secret123", + "referral_code": "ABCD2345", + "privacy_accepted": true +} +``` + +`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。 + +## 4 登录 + +```http +POST /v1/auth/login +Content-Type: application/json +``` + +```json +{ + "phone": "13800138000", + "password": "Secret123" +} +``` + +界面的“记住密码”由客户端安全存储,不作为接口字段上传。 + +## 5 成功响应 + +```json +{ + "code": 0, + "data": { + "member_id": 9, + "member_no": "MABCDEFGHIJKLMNOPQRST", + "username": "alice01", + "phone_mask": "138****8000", + "invite_code": "EFGH6789", + "token": "...", + "token_type": "Bearer", + "expires_at": "2026-10-23T10:00:00Z" + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +Token 是 32 字节随机会话凭证的 Base64URL 编码。Redis 只使用 Token SHA-256 摘要作为 Key,不存储原始 Token。 + +## 6 退出登录 + +```http +POST /v1/auth/logout +Authorization: Bearer +``` + +退出成功后删除当前 Redis 会话,`data` 为 `null`。缺少、格式错误或已失效的 Token 返回 HTTP 401 和 `AUTH_REQUIRED`。 + +## 7 错误 reason + +| reason | HTTP | 说明 | +| --- | --- | --- | +| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 | +| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 | +| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 | +| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 | +| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 | +| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 | +| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 | +| `AUTH_REQUIRED` | 401 | 缺少或无效的登录会话 | +| `RATE_LIMITED` | 429 | 15 分钟内登录失败次数过多 | +| `SERVICE_UNAVAILABLE` | 503 | Redis 或敏感数据密钥未正确配置 | diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md new file mode 100644 index 0000000..5c014c8 --- /dev/null +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -0,0 +1,1169 @@ +# 移动端任务与账户平台前后端对接文档 + +> 版本:V1.0 +> 更新日期:2026-10-09 +> 适用项目:`im_workbench` +> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表 + +## 1. 文档说明 + +本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。 + +接口分为两类: + +- **已实现**:当前代码已经开放,可以直接联调。 +- **待实现**:依据需求和现有数据模型定义的目标契约,不表示服务当前已经开放。 + +当前认证、个人中心、首页内容、任务订单和手工商城接口已实现。其余接口应按本文档契约逐步开发,实施时如果字段或状态发生调整,必须同步更新本文档后再联调。 + +## 2. 能力范围 + +| 领域 | 状态 | 说明 | +| --- | --- | --- | +| 注册、登录 | 已实现 | 创建会员、资料、CNY 钱包并签发登录会话 | +| 退出 | 已实现 | 撤销当前 Redis 会话 | +| 个人资料、密码 | 已实现 | 会员概要、头像、登录密码和交易密码 | +| 首页、内容、Banner、等级 | 已实现 | 只返回已发布且已生效的配置和内容 | +| 任务、订单、任务提交 | 已实现 | 大厅、资格预检、领单、查询和凭证提交;审核结算由后台负责 | +| 钱包、账户明细 | 待实现 | 钱包和不可变流水表已存在 | +| 收款账户、取出 | 已实现(沙箱) | 加密绑定、试算、申请、资金冻结和记录查询;上线前仍需支付与合规评审 | +| 储入 | 待实现 | 需在支付与合规评审后开放 | +| 手工商城 | 已实现 | 已发布商品列表、分类筛选和详情;当前只提供客服咨询动作 | +| 客服、通知、上传 | 待实现 | 部分数据表已存在 | + +## 3. 公共协议 + +### 3.1 基础路径 + +```text +{BASE_URL}/v1 +``` + +接口不再包含应用名路径段,旧前缀不再提供兼容路由。 + +生产和测试环境均应使用 HTTPS。 + +### 3.2 公共请求头 + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Content-Type: application/json` | JSON 请求必填 | 请求编码 | +| `Accept: application/json` | 建议 | 响应编码 | +| `X-Request-ID` | 建议 | 客户端生成 UUID,用于链路追踪 | +| `X-Device-ID` | 建议 | 安装级随机标识,不使用 IMEI 等硬件标识 | +| `Authorization: Bearer ` | 受保护接口必填 | 登录成功后取得的用户会话 | +| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 | + +`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。 + +### 3.3 登录会话 + +当前 Token 是 32 字节随机数的 Base64URL 编码,不是 JWT。前端不得解析 Token 或从中推断用户信息。 + +- App 使用系统安全存储;Web 不在普通 LocalStorage 中长期保存敏感会话。 +- Token 不得进入 URL、埋点、普通日志或错误上报。 +- HTTP `401`:清除本地会话,保存当前页面地址并跳转登录。 +- HTTP `403`:展示无权限或账户访问受限,不直接按登录过期处理。 +- “记住密码”只属于客户端安全存储策略,不作为接口字段上传,严禁明文保存密码。 + +### 3.4 时间、金额和比例 + +- 时间统一为 RFC 3339 UTC,例如 `2026-10-09T10:00:00.123Z`。 +- 金额统一使用最小货币单位整数。CNY 的 `amount=29800` 表示 `298.00` 元。 +- 比例统一使用万分比。`rate=500` 表示 `5%`。 +- 前端不得使用浮点数参与资金计算,页面展示值不参与后端结算。 + +金额对象示例: + +```json +{ + "amount": 29800, + "currency": "CNY" +} +``` + +### 3.5 成功响应 + +```json +{ + "code": 0, + "data": {}, + "request_id": "29ece743-7474-4f32-8141-31f994da68e9", + "server_time": "2026-10-09T10:00:00.123Z" +} +``` + +`data` 可为对象、列表或 `null`。前端同时判断 HTTP 状态码和业务 `code`。 + +### 3.6 失败响应 + +```json +{ + "code": 30000, + "reason": "PHONE_INVALID_FORMAT", + "message": "validation failed", + "details": { + "field": "phone" + }, + "request_id": "29ece743-7474-4f32-8141-31f994da68e9", + "server_time": "2026-10-09T10:00:00.123Z" +} +``` + +| 字段 | 用途 | +| --- | --- | +| `code` | 稳定业务码,用于程序分支 | +| `reason` | 稳定英文原因,用于本地化文案映射 | +| `message` | 后端降级文案 | +| `details` | 错误字段、等待时间等结构化详情 | +| `request_id` | 客服和日志排查依据 | + +### 3.7 分页 + +```http +GET /v1/task-orders?page=1&page_size=20&status=pending +``` + +- `page` 从 1 开始,默认 1。 +- `page_size` 默认 20,最大 100。 +- 默认按创建时间倒序。 +- 排序字段必须使用后端白名单。 + +```json +{ + "code": 0, + "data": { + "items": [], + "page": 1, + "page_size": 20, + "total": 0, + "has_next": false + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +### 3.8 重复提交 + +- 注册接口不使用 `Idempotency-Key`,由账户名、手机号等数据库唯一约束最终防重。 +- 任务领取、任务提交、储入和取出等高风险写操作必须由后端使用业务单号、状态机和账务约束防止重复执行。 +- 前端在写请求执行中禁用按钮;发生超时后先查询业务单状态,不盲目再次提交。 +- 钱包入账以业务单号及 `hg_wallet_ledger.idempotency_key` 等后端约束为准。 + +## 4. 认证接口 + +### 4.1 注册(已实现) + +```http +POST /v1/auth/register +Content-Type: application/json +``` + +未登录可访问。 + +```json +{ + "username": "alice01", + "phone": "13800138000", + "password": "Secret123", + "referral_code": "ABCD2345", + "privacy_accepted": true +} +``` + +| 字段 | 类型 | 必填 | 规则 | +| --- | --- | --- | --- | +| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 | +| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` | +| `password` | string | 是 | 6–72 字节,不做 MD5 | +| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 | +| `privacy_accepted` | boolean | 是 | 必须为 `true` | + +注册事务同时写入: + +- `hg_app_member`:会员主档。 +- `hg_app_member_profile`:会员资料。 +- `hg_wallet_account`:CNY 零余额钱包。 + +成功响应: + +```json +{ + "code": 0, + "data": { + "member_id": 9, + "member_no": "MABCDEFGHIJKLMNOPQRST", + "username": "alice01", + "phone_mask": "138****8000", + "invite_code": "EFGH6789", + "token": "random-base64url-token", + "token_type": "Bearer", + "expires_at": "2026-10-23T10:00:00Z" + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +注册成功即自动登录。前端保存 Token 后直接进入首页,不再调用登录接口。 + +### 4.2 登录(已实现) + +```http +POST /v1/auth/login +Content-Type: application/json +``` + +```json +{ + "phone": "13800138000", + "password": "Secret123" +} +``` + +成功响应与注册相同。连续登录失败达到 10 次时,该账号在 15 分钟窗口内被限流。 + +### 4.3 退出(已实现) + +```http +POST /v1/auth/logout +Authorization: Bearer +``` + +后端删除当前会话,响应 `data=null`。缺少、格式错误或已失效的 Token 返回 HTTP 401 和 `AUTH_REQUIRED`。前端无论请求是否成功都清除本地凭证。 + +### 4.4 认证错误 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `MALFORMED_REQUEST` | JSON 不合法、字段类型错误或存在未知字段 | +| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 | +| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 | +| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 | +| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 | +| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 | +| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 | +| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 | +| 429 | `RATE_LIMITED` | 登录失败次数过多 | +| 503 | `SERVICE_UNAVAILABLE` | Redis 或敏感数据密钥未正确配置 | + +## 5. 个人中心(已实现) + +### 5.1 当前会员概要(已实现) + +```http +GET /v1/me +``` + +```json +{ + "code": 0, + "data": { + "member_id": 9, + "member_no": "MABCDEFGHIJKLMNOPQRST", + "username": "alice01", + "phone_mask": "138****8000", + "avatar_url": "", + "invite_code": "EFGH6789", + "level": { + "id": 1, + "code": "normal", + "name": "普通会员", + "icon_url": "", + "commission_rate": 180 + }, + "member_status": "active", + "member_category": "real", + "credit_score": 100, + "wallet": { + "available_amount": 0, + "frozen_amount": 0, + "total_income": 0, + "total_expense": 0, + "currency": "CNY", + "status": "active", + "version": 0 + } + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +### 5.2 更换头像(已实现) + +```http +PATCH /v1/me/avatar +``` + +```json +{ + "attachment_id": 101 +} +``` + +附件必须是 `app_id=workbench`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。 + +成功响应 `data`: + +```json +{ + "attachment_id": 101, + "avatar_url": "https://cdn.example.com/avatar.png" +} +``` + +### 5.3 修改登录密码(已实现) + +```http +PUT /v1/me/password +``` + +```json +{ + "old_password": "OldSecret123", + "new_password": "NewSecret456" +} +``` + +新密码为 6–72 字节且不能与当前密码相同。修改成功后撤销该会员在密码修改前签发的所有会话,包括当前会话;前端收到成功响应后清理 Token 并返回登录页。 + +### 5.4 设置或修改交易密码(已实现) + +```http +PUT /v1/me/trade-password +``` + +```json +{ + "login_password": "Secret123", + "old_trade_password": "123456", + "new_trade_password": "654321" +} +``` + +交易密码固定为 6 位数字。首次设置时不传 `old_trade_password`;已设置时必须传入正确的旧交易密码。每次操作都需要 `login_password` 二次确认。 + +### 5.5 个人中心错误 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `AVATAR_ATTACHMENT_INVALID` | 附件无效、非图片或不属于当前会员 | +| 400 | `CURRENT_PASSWORD_INVALID` | 当前登录密码错误 | +| 400 | `LOGIN_PASSWORD_INVALID` | 设置交易密码时二次确认的登录密码错误 | +| 400 | `NEW_PASSWORD_INVALID` | 新登录密码格式错误或与原密码相同 | +| 400 | `OLD_TRADE_PASSWORD_REQUIRED` | 已设置交易密码但未传旧密码 | +| 400 | `OLD_TRADE_PASSWORD_INVALID` | 旧交易密码错误 | +| 400 | `TRADE_PASSWORD_INVALID` | 新交易密码不是 6 位数字或与原密码相同 | +| 404 | `ACCOUNT_NOT_FOUND` | 会员不存在或已停用 | +| 409 | `ACCOUNT_CHANGED` | 会员安全信息已被并发修改,需重新操作 | + +## 6. 首页和内容(已实现) + +### 6.1 首页聚合(已实现) + +```http +GET /v1/home +``` + +```json +{ + "code": 0, + "data": { + "profile": { + "username": "alice01", + "avatar_url": "", + "level_name": "普通会员" + }, + "wallet": { + "available_amount": 100000, + "frozen_amount": 0, + "currency": "CNY" + }, + "features": { + "task_enabled": true, + "recharge_enabled": false, + "withdrawal_enabled": false, + "manual_mall_enabled": true + }, + "banners": [], + "notices": [], + "task_summary": { + "today_count": 0, + "today_commission_amount": 0, + "yesterday_commission_amount": 0 + } + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +`features` 由当前已发布的 `hg_site_config_version.config_json` 生成,前端不得写死。关闭的功能入口应显示后端提供的原因。 + +功能开关支持位于 `features` 对象内或配置根节点。找不到已发布且已到生效时间的配置时,所有开关默认为 `false`。 + +`task_summary.today_count` 按当日创建的任务订单统计;今日和昨日佣金仅统计 `hg_task_submission.submission_status=4` 的已结算数据。 + +### 6.2 Banner(已实现) + +```http +GET /v1/banners?position=home_top +``` + +`position` 默认为 `home_top`,只允许小写字母、数字、下划线和连字符。接口只返回已发布、处于投放时间内且未删除的 Banner。 + +Banner 跳转类型: + +| 数据库值 | API `target_type` | 处理 | +| --- | --- | --- | +| 1 | `none` | 不跳转,不返回 `target_value` | +| 2 | `internal_route` | 只接受单斜线开头的站内路由 | +| 3 | `external_url` | 只接受带有有效域名的 HTTPS URL | + +不安全或不识别的跳转值降级为 `none`。 + +### 6.3 内容详情(已实现) + +```http +GET /v1/contents/{content_key} +``` + +`privacy_policy` 和 `user_agreement` 允许未登录访问,其他内容需要 Bearer Token。目前数据库中尚无这两个公开内容键,在后台发布前请求会返回 `404 RESOURCE_NOT_FOUND`。 + +内容详情建议字段: + +```json +{ + "content_key": "privacy_policy", + "content_type": "policy", + "title": "用户隐私政策", + "summary": "", + "body_html": "

...

", + "body_text": "...", + "version": 3, + "published_at": "2026-10-09T08:00:00Z" +} +``` + +`asset_ids` 始终返回数组。`body_html` 来自后台已清洗的发布版本;前端仍需禁止脚本、事件属性和危险 URL scheme。 + +### 6.4 会员等级(已实现) + +```http +GET /v1/member-levels +``` + +只返回已启用等级及其当前已发布、已生效版本,按后台 `sort` 排序。 + +列表项: + +```json +{ + "id": 1, + "code": "VIP1", + "name": "普通会员", + "icon_url": "", + "upgrade_amount": 50000, + "gift_amount": 2000, + "daily_order_limit": 10, + "commission_rate": 180, + "daily_withdraw_limit": 4, + "rules": {}, + "version": 1 +} +``` + +### 6.5 首页和内容错误 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `BANNER_POSITION_INVALID` | Banner 位置参数格式错误 | +| 400 | `CONTENT_KEY_INVALID` | 内容键格式错误 | +| 404 | `RESOURCE_NOT_FOUND` | 内容不存在、未发布或未生效 | + +## 7. 任务和订单(已实现) + +### 7.1 接口清单 + +| 方法 | 接口 | 用途 | +| --- | --- | --- | +| GET | `/v1/task-halls` | 当前等级可访问的任务大厅 | +| GET | `/v1/tasks/eligibility?hall_id=1` | 领取资格预检 | +| GET | `/v1/tasks/stats` | 总资产、今日/昨日佣金、领取数量 | +| POST | `/v1/task-orders/claim` | 领取或匹配任务 | +| GET | `/v1/task-orders` | 订单分页 | +| GET | `/v1/task-orders/{order_id}` | 订单详情 | +| POST | `/v1/task-orders/{order_id}/submissions` | 提交任务证据 | + +本章接口全部要求登录。数据来源为 `hg_task_hall`、`hg_task`、`hg_trade_order`、`hg_task_submission`、`hg_wallet_account` 和 `hg_wallet_ledger`,仅处理 `order_type=2` 的任务订单,不会混入手工商城订单。 + +任务开关和领单规则读取当前已发布且已生效的 `hg_site_config_version.config_json`。支持以下配置键(可放在根节点或嵌套对象中): + +| 配置键 | 含义 | 未配置时 | +| --- | --- | --- | +| `task_enabled` | 任务功能开关 | `false`,关闭领单 | +| `task_minimum_amount` | 最低可用余额,单位分 | `0` | +| `task_business_start_at` | 营业开始时间,格式 `HH:mm` 或 `HH:mm:ss` | `00:00:00` | +| `task_business_end_at` | 营业结束时间,格式 `HH:mm` 或 `HH:mm:ss` | `23:59:59` | +| `task_deadline_minutes` | 订单完成时限,分钟 | `15` | + +开关采用失败关闭策略:找不到有效配置或没有 `task_enabled=true` 时,可以读取大厅、统计及历史订单,但资格预检返回 `TASK_DISABLED`,领单被拒绝。 + +### 7.2 领取资格预检 + +```http +GET /v1/tasks/eligibility?hall_id=1 +``` + +```json +{ + "code": 0, + "data": { + "eligible": false, + "reason": "INSUFFICIENT_BALANCE", + "message": "可用余额不足", + "available_amount": 10000, + "minimum_amount": 50000, + "today_count": 2, + "daily_limit": 5, + "pending_order_id": 0, + "business_start_at": "09:00:00+08:00", + "business_end_at": "22:00:00+08:00" + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +预检包括营业时间、会员状态、钱包状态、最低余额、当日数量、未完成订单及等级范围。预检只供展示,领取时后端必须在事务内再次校验。 + +判断顺序为:任务开关、营业时间、钱包状态、最低余额、等级每日上限、未完成订单。`risk_status` 是会员类别(`1=真人`、`2=假人`),不是风控状态,不能据此拒绝领单。 + +### 7.3 领取任务 + +```http +POST /v1/task-orders/claim +``` + +```json +{ + "hall_id": 1 +} +``` + +成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。 + +领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。成功后只创建 `status=matching` 的任务订单,不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此匹配完成前 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`;后续匹配流程补齐商品和金额并把订单置为 `pending` 后,客户端才能提交凭证。 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 409 | `PENDING_ORDER_EXISTS` | 存在未完成订单 | +| 422 | `OUTSIDE_BUSINESS_HOURS` | 不在营业时间 | +| 422 | `INSUFFICIENT_BALANCE` | 可用余额不足 | +| 422 | `DAILY_LIMIT_REACHED` | 已达当日上限 | +| 422 | `LEVEL_NOT_ELIGIBLE` | 等级不符合 | +| 422 | `ACCOUNT_RESTRICTED` | 账户状态受限 | +| 422 | `TASK_DISABLED` | 任务开关未开启或没有已生效配置 | +| 503 | `NO_TASK_AVAILABLE` | 当前没有可匹配任务 | + +### 7.4 订单列表 + +```http +GET /v1/task-orders?page=1&page_size=20&status=pending +``` + +`status` 可选:`all`、`matching`、`pending`、`processing`、`completed`、`expired`、`frozen`、`cancelled`、`failed`。 + +```json +{ + "items": [ + { + "id": 101, + "order_no": "TO202610090001A1B2C3D4", + "task_id": 1, + "product": { + "id": 20, + "name": "示例商品", + "cover_url": "https://cdn.example.com/product.png" + }, + "quantity": 1, + "unit_price_amount": 29800, + "total_amount": 29800, + "commission_amount": 1490, + "currency": "CNY", + "status": "pending", + "status_text": "待处理", + "message": "请在截止时间前完成", + "created_at": "2026-10-09T10:00:00Z", + "updated_at": "2026-10-09T10:01:00Z", + "deadline_at": "2026-10-09T10:15:00Z" + } + ], + "page": 1, + "page_size": 20, + "total": 1 +} +``` + +### 7.5 订单详情与提交 + +订单详情额外返回任务说明、商品和规则快照、状态时间线、失败原因、关联账变及 `allowed_actions`。倒计时使用响应 `server_time` 与 `deadline_at` 的差值。 + +```http +POST /v1/task-orders/{order_id}/submissions +``` + +```json +{ + "evidence_text": "已完成任务", + "evidence_attachment_ids": [1001, 1002] +} +``` + +成功返回 `submission_no`、`status=reviewing`、`submitted_at` 和最新订单状态。重复提交不得生成多笔审核记录或佣金流水。 + +仅 `pending` 订单允许提交。`evidence_text` 去除首尾空白后最长 2000 字符,附件最多 9 个,两者至少提供一项;附件必须属于当前租户和当前会员、`app_id=workbench`、状态有效,且类型为图片或 PDF。提交事务对订单加锁,超时订单自动置为 `expired`,有效提交创建唯一审核记录并把订单置为 `processing`。此步骤不生成佣金流水;审核通过并结算仍由后台流程负责。 + +订单详情返回: + +- `task`:任务标题、说明和详情 HTML; +- `rule_snapshot`:领单时固化的佣金、等级和截止时间; +- `submission`:凭证、审核状态与驳回原因; +- `timeline`:创建、匹配、提交、审核、结算、完成时间点; +- `ledgers`:以订单号或提交号关联的钱包流水; +- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。 + +### 7.6 任务统计 + +```http +GET /v1/tasks/stats +``` + +```json +{ + "available_amount": 100000, + "frozen_amount": 29800, + "total_asset_amount": 129800, + "today_commission_amount": 1490, + "yesterday_commission_amount": 1000, + "today_claim_count": 2, + "today_completed_count": 1 +} +``` + +佣金只统计 `hg_task_submission.submission_status=4` 的已结算记录,今日领取和完成数量只统计任务订单。日期边界按数据库 UTC 日期计算。 + +### 7.7 通用校验错误 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `HALL_ID_INVALID` | 大厅 ID 缺失或格式错误 | +| 400 | `ORDER_ID_INVALID` | 订单 ID 格式错误 | +| 400 | `PAGE_INVALID` / `PAGE_SIZE_INVALID` | 分页参数错误,`page_size` 最大 100 | +| 400 | `ORDER_STATUS_INVALID` | 状态筛选值不在允许范围内 | +| 400 | `EVIDENCE_INVALID` | 凭证内容或附件不符合要求 | +| 404 | `ORDER_NOT_FOUND` | 订单不存在或不属于当前会员 | +| 409 | `ORDER_STATE_INVALID` | 当前状态不允许提交或已经提交过 | +| 409 | `ORDER_EXPIRED` | 提交时订单已超时 | + +## 8. 手工商城(已实现) + +| 方法 | 接口 | 说明 | +| --- | --- | --- | +| GET | `/v1/manual-products?page=1&page_size=20&category_id=0` | `product_type=2` 的已发布商品 | +| GET | `/v1/manual-products/{product_id}` | 商品详情和可执行动作 | + +两个接口均要求登录,只返回同时满足以下条件的商品: + +- `hg_product.product_type=2`; +- 商品 `publish_status=3`,未被软删除且在展示有效期内; +- 所属 `hg_product_category.status=1`,并且分类未被软删除。 + +### 8.1 商品列表 + +```http +GET /v1/manual-products?page=1&page_size=20&category_id=0 +``` + +`category_id=0` 或不传表示全部启用分类,指定正整数时按分类筛选。推荐商品优先,再按后台 `sort` 和商品 ID 排序。响应示例: + +```json +{ + "items": [ + { + "id": 4, + "product_no": "DEMO-P-03", + "category": { + "id": 2, + "code": "DEMO_DAILY", + "name": "演示日用百货" + }, + "name": "已发布演示商品", + "shop_name": "Olons 演示店", + "price_amount": 12900, + "currency": "CNY", + "cover_url": "https://cdn.example.com/product.png", + "summary": "商品说明摘要", + "claimed_count": 1, + "is_recommended": true, + "available_actions": ["contact_support"] + } + ], + "page": 1, + "page_size": 20, + "total": 1 +} +``` + +金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。 + +### 8.2 商品详情 + +```http +GET /v1/manual-products/4 +``` + +详情在列表字段基础上增加: + +```json +{ + "payment_type": "balance", + "detail_text": "已发布演示商品,用于展示。", + "created_at": "2026-10-05T14:32:29Z", + "updated_at": "2026-10-08T14:32:29Z" +} +``` + +当前版本只支持用户主动咨询,不创建商城订单,也不扣款或冻结余额,因此 `available_actions` 固定返回 `["contact_support"]`。后续开放购买时必须另行增加下单接口、库存校验、幂等与资金事务,不能复用咨询动作直接扣款。 + +运营详情不会以原始 HTML 返回。后端移除标签、脚本和样式内容后输出 `detail_text`,避免持久型 XSS;`cover_url` 仅接受不包含用户凭据的 HTTPS 地址,不符合要求时返回空字符串。 + +### 8.3 错误码 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `CATEGORY_ID_INVALID` | 分类 ID 不是非负整数 | +| 400 | `PRODUCT_ID_INVALID` | 商品 ID 缺失或格式错误 | +| 400 | `PAGE_INVALID` / `PAGE_SIZE_INVALID` | 分页参数错误,`page_size` 最大 100 | +| 404 | `PRODUCT_NOT_FOUND` | 商品不存在、未发布、已过展示期、已删除或分类已停用 | + +## 9. 钱包和明细(待实现) + +### 9.1 钱包概要 + +```http +GET /v1/wallet +``` + +```json +{ + "code": 0, + "data": { + "available_amount": 100000, + "frozen_amount": 29800, + "total_income": 50000, + "total_expense": 10000, + "currency": "CNY", + "status": "active", + "version": 12 + }, + "request_id": "uuid", + "server_time": "2026-10-09T10:00:00Z" +} +``` + +### 9.2 账户明细 + +```http +GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income +``` + +```json +{ + "id": 1001, + "ledger_no": "WL202610090001", + "biz_type": "task", + "biz_no": "TO202610090001", + "entry_type": "commission", + "direction": "income", + "amount": 1490, + "before_amount": 100000, + "after_amount": 101490, + "currency": "CNY", + "remark": "任务佣金", + "occurred_at": "2026-10-09T10:30:00Z", + "resource": { + "type": "task_order", + "id": 101 + } +} +``` + +钱包流水只能由后端业务事务生成,移动端没有新增、修改或删除流水的接口。 + +## 10. 储入(待实现) + +本组接口在支付通道、收款主体和合规评审完成前只允许沙箱联调。 + +| 方法 | 接口 | 用途 | +| --- | --- | --- | +| GET | `/v1/recharge/channels` | 可用通道、金额范围和手续费规则 | +| POST | `/v1/recharge-orders` | 创建储入订单 | +| GET | `/v1/recharge-orders` | 储入记录分页 | +| GET | `/v1/recharge-orders/{order_id}` | 储入详情 | + +创建请求: + +```json +{ + "channel_id": 1, + "amount": 50000, + "currency": "CNY", + "voucher_attachment_ids": [], + "remark": "" +} +``` + +响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。 + +公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。 + +## 11. 收款账户和取出(已实现,沙箱) + +本组接口已完成代码和现有数据库的读接口联调。在支付通道、付款主体、实名要求、反洗钱策略和法务评审完成前,只允许沙箱使用,不得连接真实付款通道。 + +取出功能读取当前已发布且已生效的 `hg_site_config_version.config_json`。仅当根节点或嵌套对象中的 `withdrawal_enabled=true` 时允许试算和提交;没有有效配置时按失败关闭策略返回 `WITHDRAWAL_DISABLED`。收款账户查询与绑定不受该开关影响。 + +### 11.1 收款账户 + +```http +GET /v1/payment-accounts +POST /v1/payment-accounts +``` + +新增请求: + +```json +{ + "account_type": "bank_card", + "bank_code": "ICBC", + "bank_name": "中国工商银行", + "holder_name": "张三", + "account_no": "6222000000001234", + "reserved_phone": "13800138000", + "qr_attachment_id": 0, + "trade_password": "654321" +} +``` + +支持的账户类型: + +| `account_type` | 必填字段 | 说明 | +| --- | --- | --- | +| `bank_card` | `bank_code`、`bank_name`、`holder_name`、`account_no` | 卡号只允许 8–32 位数字,空格和连字符会被移除 | +| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 | +| `unionpay` | `holder_name`、`account_no` | 银联账户 | +| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 | +| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 | + +`reserved_phone` 可选,传入时必须为 11 位中国大陆手机号;`qr_attachment_id` 可选,传入时必须是当前会员在 `workbench` 应用下有效的图片附件。绑定前必须校验 6 位数字交易密码,连续错误 5 次后锁定 15 分钟。 + +姓名、账号和手机号使用 AES-256-GCM 独立随机 nonce 加密,账号另存 HMAC-SHA256 检索哈希用于防重复。列表只返回脱敏信息,绝不返回明文或密文。新增账户初始 `verify_status=pending`,必须由后台核验为 `verified` 后才能用于取出。 + +列表项示例: + +```json +{ + "id": 1, + "account_type": "bank_card", + "bank_code": "ICBC", + "bank_name": "中国工商银行", + "holder_mask": "张*", + "mobile_mask": "138****8000", + "account_no_mask": "6222********1234", + "qr_attachment_id": 0, + "verify_status": "pending", + "is_default": true, + "created_at": "2026-10-09T10:00:00Z", + "updated_at": "2026-10-09T10:00:00Z" +} +``` + +### 11.2 取出试算与提交 + +```http +POST /v1/withdrawal-orders/quote +POST /v1/withdrawal-orders +GET /v1/withdrawal-orders?page=1&page_size=20&status=all +GET /v1/withdrawal-orders/{order_id} +``` + +试算请求: + +```json +{ + "payment_account_id": 1, + "amount": 50000, + "currency": "CNY" +} +``` + +试算根据已核验收款账户选择启用且支持取出的 `hg_payment_channel`。优先匹配账户类型对应通道,不存在时使用人工通道。手续费支持固定金额、万分比和两者相加三种模式;比例手续费向上取整到分。 + +试算返回金额、渠道、可用余额、当日次数、额度范围和 5 分钟后的 `quote_expires_at`,但不冻结余额: + +```json +{ + "payment_account_id": 1, + "account_mask": "6222********1234", + "channel_id": 3, + "channel_code": "bank", + "channel_name": "银行卡", + "request_amount": 50000, + "fee_amount": 100, + "actual_amount": 49900, + "currency": "CNY", + "minimum_amount": 1, + "maximum_amount": 100000, + "available_amount": 100000, + "today_count": 0, + "daily_limit": 4, + "quote_expires_at": "2026-10-09T10:05:00Z" +} +``` + +提交请求: + +```http +POST /v1/withdrawal-orders +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +Content-Type: application/json +``` + +```json +{ + "payment_account_id": 1, + "amount": 50000, + "currency": "CNY", + "trade_password": "654321" +} +``` + +后端不信任试算结果,提交时重新读取通道并计算手续费和到账金额。`Idempotency-Key` 会结合租户和会员派生稳定业务单号;相同请求重试返回原订单并标记 `idempotent_replay=true`,同一个键用于不同账户、金额或币种时返回 `IDEMPOTENCY_CONFLICT`,不依赖额外幂等记录表。 + +提交事务执行以下操作: + +1. 锁定会员,确认账户有效、交易密码未变化; +2. 校验收款账户属于当前会员且已核验; +3. 锁定并重新校验支付通道规则; +4. 锁定 CNY 钱包,检查钱包状态、可用余额及会员等级每日取出次数; +5. 创建 `pending_review` 取出订单和加密收款账户快照; +6. 可用余额减少申请金额,冻结余额增加申请金额; +7. 分别写入可用余额支出和冻结余额收入两条不可变流水。 + +订单、钱包和两条流水在同一数据库事务内提交,任一步失败全部回滚。提交阶段不增加 `total_expense`;后台付款成功时才把冻结金额转为最终支出,失败、驳回或取消时必须解冻退回。后台状态处理不属于本章客户端接口。 + +公开状态为 `pending_review`、`reviewing`、`processing`、`succeeded`、`failed`、`rejected`、`cancelled`。列表支持上述状态过滤和标准分页;详情、列表均返回脱敏收款快照、金额、手续费、状态、失败原因和各状态时间,不返回 `account_snapshot_cipher`。 + +### 11.3 错误码 + +| HTTP | `reason` | 说明 | +| --- | --- | --- | +| 400 | `ACCOUNT_TYPE_INVALID` | 收款账户类型不支持 | +| 400 | `PAYMENT_ACCOUNT_FIELDS_INVALID` | 动态必填字段或格式错误 | +| 400 | `TRADE_PASSWORD_INVALID` | 交易密码格式或内容错误 | +| 400 | `CURRENCY_INVALID` | 当前仅支持 CNY | +| 400 | `IDEMPOTENCY_KEY_INVALID` | 取出提交未提供合规的幂等键 | +| 400 | `WITHDRAWAL_STATUS_INVALID` | 列表状态筛选值错误 | +| 409 | `PAYMENT_ACCOUNT_EXISTS` | 相同类型和账号已经绑定 | +| 409 | `IDEMPOTENCY_CONFLICT` | 同一幂等键被用于不同请求 | +| 422 | `TRADE_PASSWORD_NOT_SET` | 尚未设置交易密码 | +| 422 | `PAYMENT_ACCOUNT_INVALID` | 收款账户不存在、已删除或未核验 | +| 422 | `WITHDRAWAL_DISABLED` | 取出功能未开启 | +| 422 | `WITHDRAWAL_AMOUNT_INVALID` | 金额不在通道范围、超过余额或手续费不合理 | +| 422 | `WITHDRAWAL_DAILY_LIMIT_REACHED` | 已达到会员等级每日次数上限 | +| 422 | `INSUFFICIENT_BALANCE` | 可用余额不足 | +| 422 | `ACCOUNT_RESTRICTED` | 会员或钱包状态受限 | +| 429 | `RATE_LIMITED` | 交易密码连续错误次数过多,15 分钟后重试 | +| 503 | `WITHDRAWAL_CHANNEL_UNAVAILABLE` | 没有可用的取出通道 | + +## 12. 客服、通知和上传(待实现) + +### 12.1 客服 + +```http +GET /v1/customer-services +``` + +响应字段包括 `id`、`service_code`、`name`、`avatar_url`、`work_time`、`online_status`、`service_url`、`backup_contact` 和 `privacy_notice`。 + +客服 URL 必须经服务端域名白名单校验。前端 WebView/iframe 只开启必要权限,并显示第三方服务来源和隐私提示。 + +### 12.2 通知 + +```http +GET /v1/notices?page=1&page_size=20 +GET /v1/notices/{notice_id} +POST /v1/notices/{notice_id}/read +``` + +列表包含当前会员通知和 `member_id=0` 的全体通知,只返回有效期内且未撤回记录。当前数据库没有会员通知已读关系表;如需精确已读状态,实现前应补充数据模型。 + +### 12.3 上传 + +```http +POST /v1/uploads +``` + +```json +{ + "scene": "task_evidence", + "file_name": "evidence.jpg", + "mime_type": "image/jpeg", + "size": 102400 +} +``` + +推荐流程为“申请凭证 → 上传隔离区 → 安全扫描 → 绑定业务”。业务接口只接受扫描通过且属于当前会员的附件 ID。 + +| 场景 | 类型 | 建议限制 | +| --- | --- | --- | +| `avatar` | JPEG、PNG、WebP | 5 MiB,校验像素尺寸 | +| `task_evidence` | JPEG、PNG、WebP、PDF | 单文件 10 MiB,最多 9 个 | +| `payment_qr` | JPEG、PNG、WebP | 5 MiB,内容校验 | +| `recharge_voucher` | JPEG、PNG、WebP、PDF | 单文件 10 MiB | + +## 13. 状态枚举 + +数据库数字状态是内部实现。API 对前端返回稳定字符串和 `status_text`,前端不得依赖数据库数值。 + +`hg_app_member.risk_status` 字段名沿用现有数据库,实际业务含义是会员类别:`1` 真人、`2` 假人。API 统一使用 `member_category=real|fake`,不将该字段用于判断账户是否可登录。 + +| 领域 | API 状态 | +| --- | --- | +| 会员 | `active`、`disabled`、`cancelled` | +| 会员类别 | `real`、`fake` | +| 任务订单 | `matching`、`pending`、`processing`、`completed`、`expired`、`frozen`、`cancelled`、`failed` | +| 任务提交 | `reviewing`、`approved`、`rejected`、`settled`、`reversed` | +| 储入 | `pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled` | +| 取出 | `pending_review`、`reviewing`、`processing`、`succeeded`、`failed`、`rejected`、`cancelled` | +| 收款账户核验 | `pending`、`verified`、`rejected` | +| 客服 | `online`、`offline`、`busy` | + +已实现领域按本表映射;待实现领域在开发前仍需与 HotGo 字典和正式状态流转确认。 + +## 14. 通用错误与重试 + +| HTTP | `reason` | 前端处理 | +| --- | --- | --- | +| 400 | `MALFORMED_REQUEST` | 根据 `details.field` 定位表单字段 | +| 401 | `AUTH_REQUIRED` | 清除会话并跳转登录 | +| 403 | `FORBIDDEN` | 显示无权限或账户访问受限 | +| 404 | `RESOURCE_NOT_FOUND` | 显示内容下线或记录不存在 | +| 405 | `METHOD_NOT_ALLOWED` | 作为开发错误记录,不重试 | +| 409 | `CONFLICT` | 刷新业务资源后让用户决定下一步 | +| 422 | 具体业务原因码 | 显示原因和恢复入口 | +| 429 | `RATE_LIMITED` | 按 `retry_after_seconds` 倒计时 | +| 500 | `INTERNAL` | 展示通用错误和 `request_id` | +| 503 | `SERVICE_UNAVAILABLE` | 安全读请求可限次重试 | + +- GET 在网络错误或 503 时可指数退避,最多自动重试 2 次。 +- POST、PUT、PATCH 默认不自动重试;超时后先查询业务资源。 +- 400、401、403、404、409、422 不做网络层自动重试。 + +## 15. 页面与接口映射 + +| 页面 | 首屏接口 | 操作接口 | +| --- | --- | --- | +| `/login` | — | `POST /auth/login` | +| `/register` | `GET /contents/privacy_policy` | `POST /auth/register` | +| `/home` | `GET /home` | Banner、快捷入口、内容详情 | +| `/receive` | `GET /tasks/eligibility`、`GET /tasks/stats` | `POST /task-orders/claim` | +| 记录页 | `GET /task-orders` | 跳转订单详情 | +| `/orderDetail` | `GET /task-orders/{id}` | `POST /task-orders/{id}/submissions` | +| `/mall` | `GET /manual-products` | 查看详情或客服 | +| `/customerService` | `GET /customer-services` | 打开安全 WebView/iframe | +| `/mine` | `GET /me` | 钱包、收款账户、密码、通知、退出 | +| `/recharge` | `GET /recharge/channels`、`GET /member-levels` | `POST /recharge-orders` | +| `/withdrawal` | `GET /wallet`、`GET /payment-accounts` | 试算、提交取出 | +| `/depositRecord` | `GET /recharge-orders` | 查看详情 | +| `/withdrawalRecord` | `GET /withdrawal-orders` | 查看详情 | +| `/bill` | `GET /wallet/ledgers` | 跳转关联业务详情 | + +表中省略公共前缀 `/v1`。 + +## 16. API 与数据表映射 + +| 领域 | 主表 | 关联表 | +| --- | --- | --- | +| 认证、会员资料 | `hg_app_member` | `hg_app_member_profile`、`hg_app_member_level` | +| 收款账户 | `hg_app_member_payment_account` | `hg_sys_attachment` | +| 钱包 | `hg_wallet_account` | `hg_wallet_ledger` | +| 首页配置 | `hg_site_config_version` | `hg_banner`、`hg_content`、`hg_content_version` | +| 会员等级 | `hg_app_member_level` | `hg_app_member_level_version` | +| 任务大厅 | `hg_task_hall` | `hg_task_hall_level` | +| 任务订单 | `hg_task`、`hg_trade_order` | `hg_task_level`、`hg_product`、`hg_task_submission`、`hg_wallet_ledger` | +| 手工商城 | `hg_product` | `hg_product_category`、`hg_sys_attachment` | +| 储入 | `hg_recharge_order` | `hg_payment_channel`、`hg_wallet_account`、`hg_wallet_ledger` | +| 取出 | `hg_withdrawal_order` | `hg_app_member_payment_account`、`hg_wallet_account`、`hg_wallet_ledger` | +| 客服 | `hg_customer_service` | 第三方会话映射 | +| 通知 | `hg_app_member_notice` | 已读关系待补充 | + +## 17. 前端实现要求 + +### 17.1 请求层 + +- 集中注入 `BASE_URL`、`X-Request-ID`、`X-Device-ID` 和 `Authorization`。 +- 集中处理统一响应、401、429、超时和断网。 +- 搜索、分页和页签切换时取消过期请求,避免旧响应覆盖新状态。 +- 日志不记录密码、交易密码、Token、完整手机号、银行卡和收款码。 + +### 17.2 页面状态 + +每个页面至少覆盖: + +- 首次加载、下拉刷新、加载更多、空状态、可重试失败、不可重试业务失败。 +- 写请求期间按钮 loading 和防重复点击。 +- 服务端返回的状态文案、失败原因与恢复入口。 +- 320–430 像素宽度可用;键盘弹出时表单按钮可达;触控区域至少 44×44 像素。 + +### 17.3 语言和文案 + +- `reason`、`status` 等枚举固定使用英文。 +- 前端语言包映射用户文案,不把后端 `message` 作为唯一文案源。 +- 本文档统一使用“储入”“取出”“领取”。如产品最终改为“充值”“提现”“接单”,前后端文档与语言包必须同时调整。 + +## 18. 联调顺序 + +1. **P0 认证**:注册、登录、退出、会话过期和错误映射。 +2. **P0 账户首页**:`/me`、`/home`、Banner、内容、等级和钱包概要。 +3. **P0 任务闭环**:预检、领取、订单列表、详情、提交、审核状态和账变。 +4. **P1 内容服务**:手工商城、客服、通知和上传。 +5. **P1 资金闭环**:收款账户、储入、取出、回调、审核和对账;开始前必须完成支付与法务评审。 + +## 19. 联调验收清单 + +- [ ] 未登录用户只能访问登录、注册和公开协议。 +- [ ] 注册成功后自动进入登录态,不重复调用登录。 +- [ ] 401 清理会话并返回登录;403 不误判为会话过期。 +- [ ] 页签快速切换时旧请求不会覆盖新数据。 +- [ ] 重复点击任务领取或提交不生成重复订单、提交或账变。 +- [ ] 订单、钱包、账变和统计口径一致。 +- [ ] 储入和取出失败、驳回、超时及取消均有原因和恢复路径。 +- [ ] 手机号、密码、交易密码、银行卡和 Token 不进入日志或埋点。 +- [ ] 富文本、商品名、公告和客服链接无可执行恶意内容。 +- [ ] 金额均按最小货币单位传输,无浮点精度差异。 +- [ ] 断网、超时、429 和 5xx 均有清晰恢复方式。 + +## 20. 待确认事项 + +1. 登录注册是否加入图片验证码、短信验证或设备风控挑战。 +2. 是否新增刷新 Token、设备会话列表和“退出所有设备”。 +3. 任务、储入和取出数据库状态值的最终字典与允许转换。 +4. 任务领取是随机匹配、规则匹配还是用户选择,以及截止时间的正式存储位置。 +5. 会员等级版本中储入、取出、任务数和佣金规则的正式 JSON Schema。 +6. 支付通道、收款主体、实名要求、取出审核和适用地区的合规结论。 +7. 客服使用自建系统还是第三方 WebView/iframe,以及允许传递的用户信息。 +8. 通知是否要求逐会员已读记录;如需要,数据库应补充相应关系表。 diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-01.png b/docs/移动端任务与账户平台需求文档.assets/figure-01.png new file mode 100644 index 0000000000000000000000000000000000000000..942cf377c92f4732cf71d5fb2f6b378e09836f75 GIT binary patch literal 93156 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 We96la$wQ!RRt%o5elF{r5}E)oY1m)@ literal 0 HcmV?d00001 diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-02.png b/docs/移动端任务与账户平台需求文档.assets/figure-02.png new file mode 100644 index 0000000000000000000000000000000000000000..614800bfc2d798e4b91aed15b5b8ead28ddb007c GIT binary patch literal 85739 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 E03y0j_W%F@ literal 0 HcmV?d00001 diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-03.png b/docs/移动端任务与账户平台需求文档.assets/figure-03.png new file mode 100644 index 0000000000000000000000000000000000000000..00f10735da037cfe60b133b882ea55aef38eaa21 GIT binary patch literal 60185 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%0DAhE-ts8Z;S+Io*Hx;^ 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 HZ^i!wY^Y7N literal 0 HcmV?d00001 diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-04.png b/docs/移动端任务与账户平台需求文档.assets/figure-04.png new file mode 100644 index 0000000000000000000000000000000000000000..1fd364d60e57222d46af3e6bc233782560eef839 GIT binary patch literal 64451 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

u<_{^(bA7~CP}vx&YEp* zcq031^ubmGy`SWg;cY|zCV^3`Dj!va+9R5AH$pYxB`RZt^zJ^kGmc^Az0edm9T9|; zMch0Np{DSP!F*RHTatPXCkhVuAN?n^RdS8kW=0E@6YDn{nD%Z2fxvTw1l*whqlcuirXEoGQJ89oY4 zW>{IE$9z>jwip{-sFpx-k>b%5RIAwb4VbXnUY0yxZ1l*%@>gz$2P6f)lsrDKM@D+6 zq)A13k8@SeEBa)KTn=}YGbJJeqRsAPUlg}t=N|Tl?`MC~*sJ?T+FfB|V#;5M!oezP zStlEMS%K_dWktL8g^b)QNl!1;qa{E))}=rDH|?46AF*M`q6aIS#^DS_d#|+s-eCgg z_`IQE^lcf|7xPax(_Lzt`_eBG3a+Et(x9)G!wf6B`LQt8&mLc6gtD6ZMn0Y{nhg8K zFTj5KA_8O=>9Y8xoq(Xo7{}NvmX#}D*RLW=rHjV#2!C5Lsq=nnTV>R(XI0qVHo9J7 zOgGx5D&3XFJWcCPMkRec9g`0@>SM{S3uC2fAQ=w>swDt0>yx!7pfjB$C(*to*1=7=mwTZl<6BEA z(*<@`q6o;=p*;*1Hm}rW*8gaMv zp#-&n{obP;rZW2nlW`qgmNNsZ;flN&NOcX3nEAcfN4qC=iWN0jXOUPL52Skd>4xyG$HKlmJFG}Np#l+Gc-P+RgUgj`}a-u|N3V8zeFYfANSZ{@(eV_1B>yrW_h^(anzKx L?vyH7|Hpp<_b>G} literal 0 HcmV?d00001 diff --git a/docs/移动端任务与账户平台需求文档.docx b/docs/移动端任务与账户平台需求文档.docx new file mode 100644 index 0000000000000000000000000000000000000000..787c6d3f40caf523267ee586422584e9768a60c0 GIT binary patch literal 341901 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

6Q173@p4ou;WPpA(+LrKPVt(85z)r=&!hzRKJ~g zxq2O(k>oQH{ruVhHl?mnk#LEh$qfw&_Cpq(roV0VZaE(-DA#RVVgLwP2M)d4$@~RAp zc%sLuOWh(2}3RQI-5IFdQ z3=k77o=J#Tj>Z>gf1M1&l5y}6G^y3R-7VLs+Tr?s{JC$~e%&Yav#pP)30uqvD;rx) zef?6KkJjwn;;DB?!baZzM$-C$;gqj^_;ic)^K42p8ZI6VH`lUCsj53};u`+1z>LwoR2!i$12E317&b3Bd_;eaARzW!&ZySEzSG-}rrw~yRpAT@b z32`PXjNK&HT@7OcZHU&JwJdc32*F0i#&fN|H|(&68$pwvy|G>D$=G#D?KeI+jXb&T z15Uw0%Ya;RNUF?y|3o26jOJvup_k zM$QPYOrClrYE1W6S#G@tqcgGdjAI*roex?PW1TAt(OGQ!zktQRVeXcizH=oEUEey{ zujOB~Me%@9aXcRv?fQfkFu%KZAgnv@DA?+i$8X!vHKFYhn(#D3=miD_CQfO?F@`+F zbJh+(4G$Njh_p)ELK~G>IXLtCWc7kCoPu#YdH}_yPY0EI&N>m_&PmgAiNPxiZ|_O> za$7o@rP;S{btX+$@8(LHu6Qqod)|z`j|RlUanH4g6fAsgznkgV9j)%M;?sZ}U-tZI zy6D@a0cP%GW)?oT>wUZSiP?^~Z@XDuMa8(IJclY^U8Q-xz0~tfXaDfr@z@&HQ%8g>kWlY6j%W@N{6n(5 zaJGA$2jn`veI^4QoeW6q6I;JtTv!-`E8qx;Rr>qI%ifxd5A-z#0D|)ZZB4oFqr5bFr?yMa%QrLEGBUI(63aWC5cyCkhxA>pXx>PP8Ff~k z=_D_lA_9Q~JLWX8pb{K(&#Sw-t0;VE(jM`z)CVr{B~5l z>EPJ)%YeVNN|O;if|aEel0mC?*nehVSnIZ9co@6A!EK%xA0OYjggRV!a8^vq(fA-W zRJzlE3<(WzI7(u+Y&{{24g%q{qSCOlNBtU1n^L7sx;=QDD1(f<_+R1edwo7Y3A;4> z4ff=Rw2n*zaH8ly;a~`K!?-7*WBfa+eat5bG`M%Ov5ai!*oE$&yH$4W6;gwF6w2!V zslkN{8)xj967qLj{Akg3yV_9>d%WIMRCG46nI#G3pIhWhXKW0-0ERYM&+tw-Z+PGD z9Gfr^Sq1m*o3nn3+-mDSJq|9vBjk;P6$Z%h8d_~MiqhDr;+g z@QJAv3+aKaB#38s1xtd!KK8XXu!3?2o;M8Z$fRh=lt{GIF1h{yfOU?PsE{hqP@1%r z1Q`l5OMTs_IEe`%*XH}inHVngNuYpDRES`SdpKwJ+uuKiQd&1s3R%urzf&ao5%90X zPKj*V&<;G}8cDe9cd=vsaB4V3^c^l~44yb_d$=cCUnM|RfVJu5ht)gJh)ytdA{(?Y z0f8%~C0cGlUNACXCl*G?l^DYc-wB67M(p*WBa>iX0jNi!{tj@jVnGNUD}oEH7ufAS z7Jw~mON{D9i;MIZ)IVjd@KAX$8&auw%0z&Y_iU(8gL$RUP8i(LR%noCdA2w}0c-;t zaRC577$IU+#1Zy4#3X^O{T!q8%|Lg4Q}p3RX#v)W|J32@mSd)SOVXj{P{}8 zQD4+(=YmQ(uHX|Y{(v>sh74Z*X1IH zkb@aBA?lNQli;lPWo?0hK-|Wg?)Q{n{G!ue2>qivHn4#P@$z4<5;gn&vkCSky+ z+Acz4Nr~<$8Kx?pbGL622JcZYaw4!rh*g>1$^*mR#uV`X(#+N%f!i9{JBrS-s-=&D zrdh37bG_7MKHb9$iOaT)pxuNe;=U^@#yr9No=G1`$JMWk;Bu;@ajQ!k#sAvVB!*R} zMh>OadqMTUt}31l*I@O>Qfni@EB)K6@hm7e9AhOmD!hrIJbj@|5G^P#gF%3VZ&xf; zP2`NUlc}xPoG#}UnNLd=H%r{~NzxAD)Y5Ug&Hr5>dt-=b<^MwJ?DPq?EV5Izjmltf*Fe9vvIr8Y_Aiz zZIQj9Z=r zB~UEQtLCfCjPEooLX%`RY86;R*=0yOkFbzLX<2e+*W@=^?RP#pHzi6XmITBtA4(n#17mc zGF@lUf1(tCH5j@g?6C)*#vuphaO$y^eAV-jG)^a+sl76Q7Ao+4%6>X7mtpdKz((S; zNr38B^tCmOhZ7|!XRNc8J%F(}Z4OG6`Nf7yg6-!v3EXluKjefjAD-T3&gQ^h(Em5U<|$;cRBS%^TDafA9#->a2M_$X8 zx7N@{b=kGrfzC>G->K#+|A&xqC-uGY=(OG)mW+OIuSwIQmTmk~FMyj$ zq)8-j`S}s1CN|5EFNQcj6gtva(EW44tA+jNaut5{aFl1x!z51zXqW$(IR#ngVV&kL83BLB0iSUf{KoJn(SN0w(#l{0%p z8)0iq8Xh*$_nkPElx2LXh$sDz1HU4Njo_49;ez`5`g>~Ro(=d>4D7*79|z)Mc%pea z9A5+|LyooZ>^Lew%Oz%9g>$}66iKn~Cko8X@SYCjW%JPZ2{}qZPkYX*;U|JZQU6|x zWJ6Cr@RUg;DAhDtDJwJW40m_Ip}x2O0q z=L%z6duD_iuxASm&sR{hGft%4*(qU-SxQ6FT$v2kDb;IVkh z1DSyNsLyoIM8=;jE@)PJRkM{_Riz8e&Y9K`xm<24vy$REc8w)cdL$`&0)w zdG=l$UoC-c(7U^N@<=?#cf9Fy{`XX)uE`Gd4|Sh;m;((A$d&8r? zcbM{jqzP|tkMf(R#OduJ?eaI38O4a@giT*?O8yv6RwtU{-5;aST#>@sQP34pXmmwj zL5G_pJYubw1NC@9%-l*xJ+2A2{P#UNL2D9h>_<5jSb06`{pOOVNyJ2L1=MX^?YG-({DJZ%vaXh0($6A8|n z$-f&FDJeqwf>a;`P&EG{_YM;+U0QJAkG+09F?Xo~MtM_^ zMiv5KL>dER;Q-6XmNgY$m*KPI11Xy20D3?PA`WEES0NdcbUo3A0;(k3dr)(wGOe$o5(%}IOsN*)Ki2jPkiB1FZPWT@H zlW_m$SK4VJ#Is0+3G=b=b8;>&E`OC{eM40I!aZkKq{5{By!iZ1G~ff?w33tjQw&`3N?d_hTWgbaLhl2%Any!9a{Cu zGv!+KwGi9twGMFc974+nY6Jo#N$&gjIh=nJxgyHXC6O@HVs5t;=pKp{;$K4!H^bGxxbs7Vi|P0Z#3T30A82Z^$DxjJ5Vj= zzc0+sy*OL%HUkqA*@(Ku*|^uHdq#h)S83d#$Va323o$Lb;^eBnz3pyX9_1~VyvNxO>g)IAN9o)h z3pg1)9|o^ypEjTI<1%|J|Kh{5dQj6?P6@aAunGmR_|>SYWY+irK#lPu!0LlP+W%E` znXMFB?|)T)nLO;Bcv4Mh-rtWuJVp1;&fXse|K8QhLM7xQ$cbI7^|lTm4lwAxx~lXz zy(e^MuRcVMj6zD9@B9va*#RL&6D(q5V9TNfDUz4%ZahK;ZqF36(e<8JfBxWtwbv-D zW9QcxYdBWFY-XQPE$`|`xh6R#!h-#BtMl(mzei`dfP{<@RCiFYz7Z#}M@k`C`qJLFy z&~@r#{cUsJp4Fnd@R14LAI-vg(MvmYi4#psBM>{aYDJgbjz#h$FYj$H?dk5c&*_hO znkT_fH;F?S@vliY{s6!ZqB(72Ft)I>#^d|W#O2H&Su_7OE-fuBU2aFev6r;d>FH@p zHmlSA&ir=oyFC6-;4Ds~!o)ZjHK)UQUkK>uK|9~+y#H$nQ6uJfWwp$^nwM@=r@^Xs z(kB^vuk52Y^7Ed`kjnScJbxc9jpA4Z1FQu#r4t=D6QQR6O)=UG({s5eyQ9sX`JDNb zV_#2^k9tJt4qGc7dRXBqeH>wgc(F3rpsm?_ThzW{TkdNmFK#c% z4TG0^pM`u$mdSTOCewmb8aTht12IUu2X+IaMpf)*Cua-$D)vOidg# zDwaANM`#Nj`1o#5E3Mn^2)cZ3@|PQ}c3SPErI+t%96wHESlOE=m1we8TLVrzR#q!D%rnM%Mcfy zNpcKTT@}%!Z~x-4wHhqLX{XO$lrNu=EH}gh*k{yEN*mH^HR0f584F|mc%>sojfq0Q zwKP1P?{Tu{uKwmd-M@_6TZz{Qtw=TSnFObZ>$rfj}Tgf(3`*p5U&* z-5r9vyC-OXAPMfy#R=}*1b26L_X`)j(1%~%e*d$kdwO;Ebg!9NYwi~~Qs-3d+Vbpu z_O5!7S&q6=K%8~rq>^k0?YV>i8z8PsZHswdNLhE~# zEU|qXU!Umuw95)ydaw|ZsW=5rPj~l1lloAF$O&fIo?WofZ4M&+HaS`2--OoUdl}R+ zKPy!YJD9dw$~jvzHrHg>O6bD`G4px9@ww|Cxs^sE<}T_ndz>gY6~;+UdY502PY+JW z!%~Sgz=x@-%E*k+^x|Yq!nY}K`!bu|Ae~61E;EtnTS}wX$Gs+-yKvt$!ACoELwgcVQPAYEklebKqBC^A5t6PQ?gUTQ_SHD{h2U?KR>IvS2>&Rjh4*f z7i90638B8~qraZAPX6)3($1{ks1j!+Q+>EAj6p$K-qGQg+M=qmQ+Q*tWh)Om!S#%~ zmBk$~Ca>|Za)5~CWEDzc&xWHZ;|J`PgTYL8%hN9zRZ%*+$AA^Cv)BI79LzK#cDyxU zy9|Ypz6$8=Uk~+*I4z|)px2DCl6{#=s`Ev8LuahBPdmCV3u?dR!${LsRFO{{vAtv2 zWa$%s>>P+Td`48IJDnxBKRqkFY#K+3q=4Izm^sm+7x{jp+UC*!gd|v`@t)%N9CPlP z4PJP0W!K)m2p6Zr8#86O_D3rpA|2TYS)P*f!z@enCzGdQ{#_PbRH4c@VV~;D39l|3 z=szcpb#|Iy+o~H&psCxu^tsu$T@lVrWJsEBks7}EQe3w1D{#o{Mim;aAV~;2`Mo{# z{LrS9UgMk)V7Dkada?IPrw3QU=u86#-rh|EBw#N{_Rkq+cSb5Is?^lfZEm_ENeY2q z-nn0&^#~cPj0$BY#;fJZM=+ld?>RfY+@Aa8S)XY?#kT`D=&V$06|bVKj7{Cd<#-!l z?@Gd|68l`8m2MVbw$Z{k$~!@RzeHB}-5g)C)NTzjl7*z|BJhK9)})ALW7^no#M;*3 z1%Eo~rt2po%Tyi?=i7k?X^Ar5A+zz~COiZ5aP+WVUDo2NaB`_(jNT0+vxHu_r=)&3 z9`p%$p-QQd-Vbcoo7`n}# z?l=2#=T`sDFVYfYpZc$77#RnU_S$7iH?g}R{gYw&dtMa>DfxAAEv@xfTMYEUO;BeB z!3c+Z=TJ`GK&fwPw?DSQOJF-Bt;K&kLcUje0PCf{JjA6BFGJ&RmJvXirI7!2&JJBr zu!P$YI|Dnr?R3jl^)_Q-BFW#p8qe}|jokh+`kpOYmrdKRzAq#3Se-ED2+hr{tIZeY ziD3`?Rrb|B{leo3M|V~;rEBYlg_=oeX;ujeq^d{9FzVJOcNdteHCT`Bm%V$K=D7cE zL&No_if}s}PKJv84LPPJ&$~a_xgZ@5>&d(y;BYHC9O3_ZhK?~5t4-*hJtdX#XN-bx zKLLWjeC%7nM|R5mSKm>c@NYYfv~E&IUD_1ai+-_Je!|Dj#7^&3=);K8ybSNki#pPM zXwRL^wsn43g-Ab@f@C@WN4^VsB(!>no0m6r6`@T*%k#RrzGSSZ`6IFX z%L|h1v$2KE8H0(L$Ra+^iDrM^xRNc(JHvHn)q-?{J7?AH=$8)f8A9ssS3#7bt##>l z+d5;Le-85Ku)lNL%pazcSb3!|5guw4rM-!iZY0JU$y4D^Ga0&8wv?YRTGRJiQ-7-E zV-FH4Jno-!l=if{Z6A^boA&tSE3qjM%}L1z6651MeB2yJtXP?S%+wO5iyuy^FO?gh z5~uvo*pl-g z#`~4U1%gFJ$&ewnFKerzi@uOw?tt5tg1uPVVK!7TW8!OZS2^~C_AV?J4C-!f9C3T! zayfC_*suMFHD_3?^o;*O#v~;ix?O-PleaM9%gSlicAv&k>5afDIwy!2_KC;+s^?a1 zF!frSR^3iuZ7gGNYKuJL=M|wcZCu0V;gEUzW-LZV%Ux?oXjnw#VP=`C^kF06Fs@`s z{bN44i-`}wOC;u(CA${+)C4(u&e~=Pg?5zH?MtH9AjY6#b4d-y_WN8&-n73p9Nd@k z|5&hS)HHY?$n&RU9u}Sm3_Ax*^L)*I1sNW2nxBi1GQ4_cs`>(}936Cff&wS0T5Ogl z{AAAso|YpD&JHv#*iZK59Ui$+Tc*@h_y#*X^ahi&NyWymtr62;Y zWPAQRnV#s060!T_^PP)wAD8o(XjD9%XfWMj9k?Qj&X-SKV3)qs@S?3j{G<516)VW{a(E1 zs`e3$9~n;32?OD&Blyp=Q$!fx;n+iIo+2{{7)b0JUMQ#`fHxBJ;weP+>Up-=Z|ryf zbP|jAHQkH#O%uREm_r`{^tAfBq`|hbC9s zbqnh@J#i(+$t}%j@eSM;Gr!dBfUe3Qc4;Bj#D{UsPM;7pjcRdG=VS!JdJ)z z(hFx2pGpc*wzmNN3I*XshEy0V6&%Kh+D z&)@BR^Z=scNmG}RVHT$z9v;3|r(ea%a&ReLk_G8A01r48B89L0<<I3w`LuoO zKvZ1bYO&XU`1=`Qlzs`c)yQZ=l~77rIwd01U<#ZP`HuH!JYHw1x#p@wiT%T_*s*AT z`e>f=-hw+xtcvoMD?ZzS;wk6aT*M4P-Qn#7xrVm&#x(yOdb%dqYYifyA^!S^)99k+ zcNT8rjIvVM7oEQx9jYXrJ^PLTk9fYhNZOS7+)@Y=Ia0QXjAVu z8M@vd^iO-*{Eq~ffC0|KGX^R8_{*}T>?5tbJph5si`P2E2+_VN&7NMTP3W8HM&1yeZeEvr5AN zFhh|bxwZ8?n}S|PcXue9a=hD^1X=87kA1AaPUB2~x#3D<@G|a`fyJxAC~h7n+0CEa zrZ8ba;laxtdt5kM$9#)U#1A`_8vRadEWI9Mx8hqkvUWXFmajPrGT(uPDl9iavXq4Q zu@8WMRu6+qB%ro-Q*9c5qWP}04Mf2=s^_Yu#uixy!&9`I6Bb!IFa%o zMOrNUcq8lKoH3eu11bTpRKC*mh_;Nw#f(3gdk15MSmB@OhSnqcM7~N|89xk+;5_Ni z0RV+^)mkiwo>``%`C?_Fk&Is@3bw7(($M{%6Sc9~nRVL)JUS_ECR_)qBf z#b@VdwRh3aahV^ZMY0FeKazn{Y_&NkPvFv;E!51Y+_!$t!3(!|K^{7jBW}vxV}i>a}~IWgD1pQT#S<%Ho^d z>BYo7tLciLBKcP3h+Pqs7KTt4}i9*Pn74UtA9x5E5E1C8?yxaNZLic zv^|)U-_`!wQK096MgIt&>>_^6a*c?ZufZzIedTr z#BBy??3{P;ZwsPC$6s<4a#K*75GNmKEDL~)Ii9R56}$)y>-$PB4vyvNS}Q+nudCg> z>o}3@*;!53^yO=H+p`ZLh}t zQ(tjkT0t>FK4Q;^iTO{d;mprCBP%22-&U4;cnEXP<5GTK(SlC87)aLmEhiSXI_Ty0 z))Ib_QKUXZEyhJUm-OT;Evzc23plZcGt{vCfE}+|b-apL^J>Nq{$v8R*cY149y4hb z@_!vJ<8bpHE!E7pTUr(q1Afd@ezell&x9(DS`?6<&g_$P{q&(6RLygvlh<0JR~NP? z+=uVku>l+U1xJW9h~ckqG>Cx_P z>FBQGMd#kQGjgAjyEMT@1^qu2`UBs;%Q~~F;6&7VJk&Tb$(wj8JB0@ZmaP~3{HZcH zBzrJbH1``^r!2Z~l{AazobVP)IyXVDfsZ(v-ui3Ns-F2nsZ=21#o$S=*n1^EYqiU> z<(8uz;kWQV;=9mcIBQx;(;AAytv-$S#UJs$2iQE5j4gBOGZtUUTN2*|tt^QcZTj#M2 z0}4(B9~!}9E6d8v3?WxkY^~Ph-kxZxBFmzZx!d>8%+11j-#%d)(+2!6M&xh9?DV*g z_tw;ZGv$Vb_0i3jc1ID)ZnuU;2X-qY-)$Zvc)pd9m)e3<)5ke0Iez$`DxFhP+EW}F zl7kS&t5UornQa~W{lz$|!rmflWT3xT3DlM79(}=0h%J$kknv+h8{ltBN;Suv2uu+3 zwffaZR@hc+v3i-4-_-YWgS>U3z@MVI$_a;$7uR~j7+Dd}U>kgcU>_Uf;ev--%s^!n z!!0LqUgnU*>WM2Cj+mJ@VfnWEvx9rgcl1QHQ1(=*l6^BDei{M5eH$w5r;DQC7A1|2 zNY;A);-3uzcll&_pLJ33lXr}+?yw)&2TO=+&}7;dmM9JNjQxos{l?>eGe0+9s@p8~ z{>RW{VnTv|)&1ZY`O9~YJSEvV|C0r3n-(V;x#6JD)ZD=Iu&|Hm4-g3)Y>9FsMxICx zzroQr#-LVjlJ^)$c_D$e2`Z!pe1+b2E4r26QJi%$fA;zbjUBqyFC? zD^jG1<6oFA)vRum3l+dZ2IvH6TVY2h@Z!CdYNP8`gTT(tCSFmI<=64NBRiy@`8;mMV@$*>t&BPbu2cO98z%wi4S(^ChQ~(Kns}NikMmb)1UAp_pPddb*vsiE{wXq6NY~6e;2*1^r(g2)gJl&HFu#N#oRkS6pmA!Fm*=kh-dO)Z7d9Bw55B!wf4@uX#ZQQiwq#0T<1E>yRs-kJER}EPtiS_)+jkn=T=Zrz$q> zB|jU(y>V|MvjMnLi@Ta#GJhNol2B_o_esFs`<0ZmeBah(RP&Gh|N;N6wY;s9ZLL z`pfhtlfs#rvXinQzVy<|a`X&d_;00LS}FUCw>dGZbXYCo0%Dx(QnreuYIgo6GRLnslO33FTO z{SRdmq)Ux&4>?FdU|;&D2mvzE*IyK$d#+CYXi~}#?`t@n=9T%?X zr(;5R5%vIUTDVbNuE3|`_wXW@0rYfmqo26kpN?t1vlJK1sC98&(7*}x{oMt`;{T8f z{TE72em`Jjd-wcHiV)h<4OCC5Od6m6cJlB;;p_i}v7!H^fu{cr54*2uz58OHbG*Uj zmn?TxAp6Edl>_O$3={+7%^X4hGATM5NQ=r zOPM*_@w*n1ODQJznG2+=fvblNc)0OTf4dj<9%DX@s2Mt()=J94vak1;rL;k%kil`M z_lVWm*;#iydUPSUvwW}Ua>V_4m6Ng6&w5fp|iVt3Au#`6O$fE1XS*;Q8xFPCAmgh&qQ^y^$z`b$v?&jvJ3-j zReGh5Sx8svVu_{8;}Q`$-wn62nvZ}d4F2esYSo|J2g~Hp6lgA+{~n!#;DGP_-oJm3 zPQqV4LqJG4X@AH6w3A6rTr%wC>37P38QoSlQL0$I2A4)Njul+ubXwMJ2AxWdbnO0k z##X<}pOXWi;k1&8J+r|SLE~wkgLVVXie^vx)6+5I&}iaRF4F>6&rml;3ADTU+lz6q z2J}eqLThOrIoN0AA+Yts(m$q26(cGuz(+AKm!& zl(*{mYkNbck4))j?6m7eusVaTgR&(4#DYcy?t99Aou0a-dzWjn)bEA{UwQ?OS;)5C zq_`4*Z6>n}VLSn(ljT}RxB0QSOnP=JjXQ!p=-M+yS|d%q#{EGD ziSpXG!%6IqtJxiU@a?p*;tI)Zx52!Re#7mok9-p}_;23KRj+Vrm)RW$|onP|vEoR~(jim8|-IluL4m3PGz1ZvtR`>RlD&&W7+b97lkovmd z)nX?;w7wl}_0QmI#2LwK^!PK#*XZo_de?bxg?`UIU%AaGPta|B5||U{$waHgn@4ff z14}kE2o96;q=l}Y9+lipn@0VxpI;lvEw0r}ljlfVU~aB48$P1BIrYJ|aCE@cU<-f|EAJ)NtqDf}Twd8(%?B$ejnF3Y4V#7;cDPqQhJ- zq$GD!PYjq6wMasPgAp%wD<<}#$BRBk>tX%ag2`-loAHpI;dDNi>)o@gED6xVvOjRC zSd9+4|HfhK{tN2Yf8<|eCJyc45d+)=g0EtYae%-7i+9~>B4>R3-yN__g1 zCrhP5lliipi1$jhLYwlkF8Sa#QpcY}&@im3&o@TUAozR>hgm0nTOA-AIeG5ew>3qG zviOlw9SniQ2%u$$j)Ea2B_#rX&Ew78!m0i4NMa7h{=sH+IqsN79YbGF!Bu4i)CqwFp}{t3*Q;s0lE z13hup)IJmKN~@&{Er@hHO`FGqth+nsfRh2qN#sGoB?(`SN`0HH4*Q*%B8h}V&t$5E zm;?zxxzA%xBMXl`Tm5;Oh)g?1-`@eVUF!{ z;Yn8q!8SLlK7Oq1Q|Yfe0u$XI^p@KSh6D$%Ah)!dE)4I*_5hQx(&nsDrmQvnM$B<0 z!#B50&ik_=RSLVi;nF<&`HuI>`Newvz(X(!9cHQ~7;h3}St6e~Q=wD!sS-jr}n7nRI-WPh+&3{R%}aWi6%I z)c}Y4bllIbh=OI?PoFjyg9oN+EjScF=5{ zOs>2yMW3uvk@0IovnHRd`NwWQ_lNd;K(vV_!HM(2jNv(ybV($=TN@JS2F-R8^OvcB zdYo;Oi`bSU(80{@8m+iPg*B~M-|zNBi|+d_^Ie{cbP32mxb%09$>DR>2*4aC5`O(U zm7dz?<*_m{GHQ0Q(*Wx;3S-ppq>FES*4o)D@?T$HFY(ou`bCL|PV}&`x|XiPshP2V zd0Wnp*wEN$t6AMSFaQyK6d_uu-JkOBO(4;@yWwX|99i){mTKix!jhwc9G1QeBoj`J ziHU*6{ffemlm`*8I!RVMh(2$+(y8_F>*y{!UT)6Z?mtjxtSh*p*Ql^xXtRcOGZG)$ z0g}b-{;*y&@fZgyta07?{hRj={eG@hCUj(f`rdB8wDy2Qy;#xjcGEETN;|`>e4DxriVdkum$j9ITKSt&w~3=SX30P_BXsOdt#+30J&s147c zo(VQfu*lJBmb}1kmf8{g+NV`*OD!?Yhjb`XWbfP`e|iz+j_n$(V_Ksx?CvJL$)$ov z5IkNeu})1e=-Ud9(z}zrv$HuDdWVV2Y1*9@c<0Bc-3j)Zq7$^6H~R4IAEfM|1`sY= zu{V{&GHz+4LScjnKT>o}1cA5K{^;;9Z;XouYx|FfclB-xiQ8A-{*v z$!^j+YkVyDKYxUmHX}mz6uvaN!3q!}5?;Kx~RXfz4`;!h0}utmZoR`HbG#j2s=Ijzu&?r83+ zyxyIMwA7>MMwTe4j~IgzxU*A)C=|*2M~_7PF|NWFW_rb#ZBF(h355$!LtbGLlMt z(~pnn>|JVZWUn9D{bwBY-M{D~3+d}Ib>13$gN>a(VZErU7cU(wOBFj1d!8YZ?cm~~ zTA~JUJ0IVB;S%;!A%R|OFfdiAnIAFrv&P2|R{ zoq}Y#ouL%vdbh|)9ZLHgEHP)&NiM6O6zuq=T~bxFc#b*npau7cNt#tJPlGk<(6OP&#Zrdz7(x{x(uP_s{3i zy^FI~uL@M_`6U@y=$hQ4nd~#n0R|X|qx*5oge0R;s?}hUPl{wvXOX$YtP^!Ohl0yw z!ICVW@c{B&z{!_Mn60!PK_~D7@X3vWPTf?A&cry3eL`y@ixg?XY?%I3xNY0O*UI|rdA}e zASD$?omh2u0v_+i`g&Sg+N4!&Z0ssoo?|Fa;Gd)x^W95=dJ)kMIv-_@E-PRh%#STn z2Y})PpB`zA4|sbz>3~EmueH+0^TTnOZw=3fW4--3R~)lti{4t*dI8Gv=!MsfDFgDK*7571+-YLjIYqYf@~#t$0NZZn;YsbrH`9OEcaQ|t|QcDW1>zxf4*^z?KI+V`$p^ldPp z*Jv+w(y~Tp^0itnAq!)qaKfAb^bGL*{%D=p%onJum74!Dz>Qd@(($i%m+kL&?ZNjs z!ngM4fNNF;KK8=8zcyPvl6lmZl9Z%TujwsfH7DWsS}YPB-~0%OuKUr$(!|8Zi7|CY z2f59TI1&IFSK6!BaIGJxInKb2(V0Pu$SwT7(=BW}&t08dY$ku{ z?@MlP@ALScH(iMoP8PU0V@muE)zOESRz>3n4)vNAs=rUHh(RR-u{G6}@*EjT6RGbl4^8 zMGaS1ffMFK3%@L7RxbhUT-xz zja|SzjiJMEnY1cB=8CZ+c5Xeq@gfWKny=)O_#nCCl$@nnEH0f5%sMrh4~I(-ma!%8 ztNN40SbFoQz)&=Aj|T6C=H|xc+glIdPg67SYh3n!$7Z))!^2*ZNS}I+dWXvd_&?W^ zcPN;BTCl#Ys9R034BNP*RIjbntFf%eJ%kAhgx@L~&g5)Vd!LdDEu zjc4kZ$^7L)+fT~y0uOc$CV_O@k#w-f_Mzv1hx|Y(NL{<^_>LPTvEtTnvCweZu1Vd| z(Q$iWqNlT|xmER!0^pSxs^^AO){BL0xw%ySZ7qItRgKNf+`PQaT)f<=kB)9GlMNIP z?hDWrvH%|C>j(QocUn7> z#DIJiqfaI#fNzzaMu>vbyOOPkZ~Jqa&7zIH>-TadXX$}K;b&dl#WPG0!DPuqzKV`D ziBho0N56|xz)f2QhPh`sdhVy+Fnox&(%PM&MmM#V+w;hC?!J6=?sBv!a3ez%OUuB( zkZzlvmS#4rA2T%LuK1|WU3Ew4Ps_$|2uP!2Zb+@wnc6;TZcZ-a)nIQtB-u0UDTJi) z9uqI}qRD7|a0#mHjX`LaAv_svN%m|NVVv8riL3QmJRKTb{0z=sQdy0>BX1@U?aBX% z34ZfW{75M0Z`_Gg>2HWBhURZHNfPUC6pAAJZ@}rT*uNt|Pw4;4r~kkC|Hqmun?VVF zqcm_8af!p($jPP&RIfZA7oURr99W27_g%;$f@TNqaFtR>n&e(nE}4ci`CI zdI6`_(KafQ)OURjPa3h7WB-y+AFh|B5I;Ezr+x4faGRQ=-$vnc;5H@-QTTK5wU_zT1Prc8}@;*f=fJo3NgUBW=j7i*|`KYm4t3oPjNflhOt#6zf)j}eH8WojC0yZ zarmbb!=|DGjm?hAk8N(&YWbw(_UYo6JBakiG?~Z`0kf-(0mVNIk%I6v=2!Ie6hJ?v z7;y@mhkt7SZFZ{i?$6hAgt$TrsnlNJ;R@prVmkhU2Xh4HuRV-S3;V zV4AEnI&QUdz31xi?caa@!h>7&k(`2qYZF@_{l8?a8e<*m8`D{J4!fQLO1!%G$c_K9 z$EE+lrj~jegFlA?D`r>{C{qy`feAqtz+SyS6~R)Jk58k!VS0x0?^h{`%y3mZKvqlY zzMHOX3#0s(qY}b^Po@bc{5FG$2~u#r30!p$#&EjvG&_ylBk(6+n4?J&>DXSPAV5%A z{TJ!)Hp8rI`HPIapH z!*{a(EU;$VYKCyXw0PY}zJ2@dchJ1$)2sA6hEj?BFp;HtMF=NuU<4xUa2>dM%9dKa zqMN3k*2H9_h~mztdhCYYyE_(nPG)&R6a?*&Yd`3D-`S$AFIv z3?!xGy&`<=b-|V8%R1mFNyf&IBN|OM4N9FEzO&oTkBGCOZ|`Dr19kJU z@YUS#n^}xpsL?siZ;DFhUzoprS?i!d7nJ0o(xQIh1OVOk6#kDoXk7*15#%nykUA*F zVN7(rMx=ru9*fm}uf3PJSl4S3Uh*zvg!7$rcF&;8y~Xtrx?6FYr9+PgR-&$WOX<pcOiren~Y`xHr9@z58jR1?00+`Xb@Q$>HPMNrJ?iG<5^!J zv$iDCQ~^VaWxhsu;ct-z7e+|^bi0I}V zZOb0}#2{4rgE!dL_>F@;AKy2kI~AE-_<7--U4!RO{JwcCFDIZwF5Rk61k_bgi^ZK( zQNzeF<|8Exy9HK4sSL*b)-ew@$Z_A6M&tS&#dhe7ZK{Aq=y|vcO{9afRD0!hr;oYc za2Dr+XpmpVT6{p(PN%7#sc1H1FIIPOcfT{eYa>qAd77A|A+aLaTRud=SLr+l$$b^l z@z)N}xF5svfS)bCk)>W77q;>^1oj8%cO3Gg?qA#vEkN1q6s;F0Jv&~yk+8O825Q47 z*lgCSE;qz4INyB-1WP;L$wARxpSMEi@ka@+oScm*YUT&qKb=Z}9QF$;Ef#BU#ZYwG zO&+eCQJ{PMwW&8NBdovNsnhiZKko)9iQbu9LFC%n)5NR;i23W_MUrK=_tvJ%O^t;y z(C%INy2^8MjD1>;8s_?$Q%4G>s^g`%f-F(`6X~}MM$<0R2-$N;FJ4=;+D%kA6{p#RVS zrD9vjK{EPl?oVdRi7o4c4e*12sc(zRa1h~KHM^~D1<)7`%_?5GNR=)sljmxoO|Zvr zK>=7O&*lm0kMsPBRuMfBcGlocGUp|nRh(izCN^s;qBJ#y6lWFvBox?(-UILDH+f-r> z4Spn5V6sxlgqK$mb2!kqSHn^ZR1_8(6_2KqqXJ=x!zNaZx@F4TlzzmA+Q}mOEWv{y z;mBJAY{lG!p-0O7?Lf8yIGUJ&V{?`~D@{dqhC+N&jQOE27{MZ^ZW`ZmNW%m8J{}O= zX)F}AMNn?^ZLu`!I=IB-@2*anEpE)hkDWKne2>{=mOI?nVl(LX_>3!`*~;cMZUezy zG~`RcUT zP|h!#pt5FHp7cK!_l6sFZj1yHpTyI--TM{>jBi?`60iZ%Qpu*;>j{zxTerq5e!buR zkVbC~oiJxkslYq9h{A`QivAH`hvj74NI%Ao)(YB1Mku_yIC7s(^zm)kmLplJkht4C zKwNIjyfteEsGi2{Zz>`pa#C)KaEFD59qZ$<3F!H+nt=b|=3E)?m0F4J^3Rt;9g1=VlZkJa311+sxK1h3VF@f}7`-3ZJDcsRPG4ZJ zHQ^iJo)}X)wvziArtN}{K|o;cxmfnt3($<1wL$ZTNB|L!vmB>g{7scc82aSK^FfK( z&sbaAfickAHy^1G;|~|=j?t`=wP|G-@BOhr|y5kuu+S?{WEufCeVVYAg8IsdcVVjG>BDs!|SU!%R0 zO-^m4R+6;6EuMRJK9y>4h3aun!IoO#zTRa##>aFMImVYnt44n9D4E!%fT)_u<-2^V zS)^*Gm+9nCD_ykM;FM5Z9ZM;MbIH8BZzcb|qg&R0S&VZ*gCWxjwrj(Ad~lWiy`F*F zONrNcgVpyF%C%pSR@RF;Y^4OC>@Ug~foNsH@99rtit+WU4t5JxXq z@gZ6oyH*~1!uSjK@?cUDmOY2v+}_rj5&+FE+;hgbI=j0fay;%Y8mmmhQQYX61Prf} z@UZ*itsYEafVeC*IU5|9MY6-C@>)-E|7OTIKP(#)xDm@!0it4b0#&8VI`2(+mu@LT5eDP$I=4`Wa~` zX5Xb8g%4H6|z)|s2pq$99)+&2?b!6LNyj<4e!>rJL!9$)*Z^ad(CaMv-1lN ztb3EoTLW!QYt4Kk?gJ~9L6R0%2HQ zzAFMJDk{v?)w94L?ayb~B~uqbUEiS-*UIK_*<92pcL`t}0v9x!4NLUxulFOGPIiW1 z?nYN*D^S>tn$eYZNu-XO?PAry`RrS)2w%<7i3!;03aD6>!JH)-OS)^pmN*Y=5u%0) z*wm}#j>*>CAvN;KJ!T@n!Y6{7NuSN}VOQT$=~>42!83E-lr*;7(j-_B{Nb~c7h@1lpl3~Bx_ZUM zo{Qz{TZOUOBFB#Tc=|ZKHt3FWS40wG00pw&Td%bhYs?4E(Q^AybE4hLYpGoTpvdF% z_b%ppfTvs{mQ{Y$yVCG>pnKQ3-6boNf39 zKyV`E4sLE)y_L8TG7Q;R`GV4ad8U!hT?s#RsPdm$xy5GylA0*&aG zkB{Ijy=N>ic~*P*jt`Af|Mg_#(V=CuZJ_#-ivAbqZF9DV>33Ajh&Qv~Y$4CWwis4wYVXcNHkFoX~+DQACZk;A9fZtaP z=zxUs`N>*i=Yd|%p3dWn#EPZc`|1>@#=)sB%F7TVBa52TGwADxZ#4(0!@&41`62O4#@l_?c3%S_s-Nit+sC|qWPLrTA{xv7Q6PUgv~m~eMjZ5=YN8{nN5z~GZxZDE$wYM@TfJ;y{BU4| zK_zVWG9=v_omjw&F3YzX9mU__`b34L(dO{m1NgqFekbeWk+z>wbul;PJb9z;a+clY znaa-HjhBu@|4^#H?f#`uut=NDP{9|sq3+iwsk0RC+%7uj8m@zqAsXGC6hrSV8ZkRI zPZHS*Rc!B$1s9j}GC60yMM9wGziPe)Nqi#We;6t4Q!pR(<7&HuZ>mp7Ym51>xq|!p!P)Gsf9(E43yz2ks!X;v z?a&b^EWjYg+9!`T!$6M@e0wjqG#L4VDXNN4015;7!@7?a+>aIBvHy}>dk*+8z8F}9 zIK*pvhQ!BjaWfq*u9Jb;;NXO@x9UN zQs^H{t6v8^FsOIx*LDQRP!gBV{9*pY4im{_bF3~C`0;dXkgjGy1KYwMHlSOLRRbf55`Wr~SJpz0=Oc!EAw4E!~#H_f${V_L00Txaz>J zhM6LK9Y7mA^{{;a>HUco1$<;R>HI&RAFA#sC`$DPy1A>(Q$N~!>gu+Cv-jE_PS*Ry zIn7o$Lmgb{j9bp2-0kNvzaMG;{uOK{J~jOmEXnnTChC zov&X(4{v>8=@KE8&oW51)rcN7?ml$B!!bRVCDbOVz>e0JzCO~_ohTmGZf$@`F1D$h zvG;Sy?{AwnzB76&rnL^sOi_0(*OX;F0QHu*U-A=x2=i>23-K$$i-o6p=MQ<`&eqP9 z9E(pl_h8f!LavixJ*iBKi6<|b9jtAOYKKH!dsBsoe&1}^4$kI0b%;^9O?YQ?G2b5mM*cMA)5fAgZ32GTffou;zbNqStfLZTOl%&B|EI&L$XJtzf)oA0g!(eFKNdcWAs z!F;OkZlds(ulzC1`gm&@^r|FRj*m)H-$jZC{`ugye~*3+o@p$*Ie)x6*-H87IGaLJ zkB=(;Cj+tyKq_T_7_1Zun(nzeMr`EbPGEqpWlAV4HeNYQ#w@cW%a%-8vZY*K2UX=q zXi-_WI_wqm7YIj)(Q_=mZXvkXt<0<#$-yRI(r**EKOpvW+M-!E_}qmq=njZtYDIni zm5q(A&-uroJ9L=sT*uJwW>IlDS69b!rAy35kqT!CoDj76Pg5UKGB`tiSvI$g913zA z-*?fY2Lymd^p*h$mfjlBOh{xn7PM{uRZCC@fI@o*@E5*DCnPAX0*7h6csa>7JBcVD zu?z6oBUfZ+JkF_w(zKAv82_dAB`I=DF*2Uv3osgSqwmn=N0x3ZJnN?aRNR&SL$&wu zV;2#Tak*Iv*-A}96DDGmWn41J*m9AjG%14^Mp51DnPh8BLStzRLUxxDvKwPb2HDb7 zmZ1@5<~g4G`~%Mq&+GZ|yv}*OzMt*=KJU-@E{D@O{UtSos7_A#+;GFgYw+i!hc*3t z^6m#VBbPNvwQ#E`>`>hyZV8>^^;hs({a?d(2XK1C%Y+;j%`2{hM=zUzTqHm9rc@>e zNaEqQ?ZVESpDHM6LguVS$Q|>XPAN53OF>?&doFN_AmKPqLKf;t}LHdSB2 zwo$e9RiWa0>7Va*~)ZQ|bBgHJ9sq>rYjn zfz>7hIhoL`R3aRs`>4e0n*6!VEf1<$3U>ggjClivTW_hEJ{X8E;ZqTR9ykW&pJ7IR zSIQkhm`B+2b+$29+^ke^u2%bjZ~UmkuLpefWiIQ$Uh4v#C7s7H(kOwmPp#;k7J_X( zW>}Hb`wpX1u-$@VBc4z1&66Tv06UQb3=nb(To#5_IjGg$nKt7`7VFcZg_Q zjl3wOR!<1X*(jqrN{f05paf!iUj&Zn^5@x!w9RR<0H`}66s027tXlGqsp5j>4SV7M z6|LPz9?ZO7|F5RTKG(?n^L*9+dWP-_`{zMp?!>YcK~LJ!i!o#e>a^}gLm{Bt z@`YxPd*$k8aY7d)+xGJ(bw!@>mi^WQNQ>?7+jDF+9Be??7uiROPq5c?e{nPZRR6Pv%5eJAjPXSX2}SWF51k2r z4aB@TOEv6q+3|iGfdc!=9C`Ym2gL4M7dmww0M%sLh^+>p)qgk;s~}R1rc_?nB<#Un zyR_*wTV~^osSfy=b?)=p9@6-Q5?0>#qJ}? zp4DDfeSbloW*hF2XdKX#R>2m43;IQgkJiD_$GvQACED{ z^hQs*%5C(Ma2L$)h0bq%mtbD%B6Kegk9VJ8D{^qO;quvrNcHh z&j|!X@gJ9+$0}!I5ENvv#Oj6fwbq6D5Y0mK$MTN74o+8*C14W7#3TbItCa9nF-a;? z{Gk_0=^Kg$ocU3bVbEXJS(R32R|w@4UzqK zIbrcZ>DIo5_SQ9+L6wag)XCb{ft=*TEp-5(&h8W{vND1yjk&e9gE66=jdKs4B6IYX z!0_*(Ww!al!skD@hPJ) zSfK7PSr;7%^eGF4GD~&7+0`WQ&HC-@g<7iCLQ^Rt5W)@F+w*g12dRKy3lap?` z;v2%TSVp$KU2e7ZAt;Q)K7QkCWkI?Xdt?1(R3UrcH07`q|4+L@r_kjwwcg-^iU!LS zw#pfjh33I?V|5Cor;&abDPZL(SP0Blc_U`Add0REcJblG)`GF#}t&hf|$8b)M)jDmE5J z)Llb=A6SwYA?GTUZSNx*-P*ws`b){RDfex!mbn-TDC@d#Y?AD|>6F#3Yg`3@p}k9^ zLjkI|nVQu-==+X&gGP-xZ-jwzRcO|?pOX&-ta^r*)-_Jv-ej8IOmjPSvGYJ+^@qX1 z8PtQ{vgs7=BTZwOL)R{GHk*u3k8o>QB$o3;brESt(r+S+w=q34o1y$zR7F5al-kv)2IdPU1j zs;%wC$d3tB^Ysc4{bYhWpU-iA)l_VKB6n1$V+a32^ZKVs!`=-LsTlq2Ll~3*JgM>K%1SX+f&TjtHR^qH6!7=8p`{H6hZ83uuV{f-;z#3yqZw#PIH@at zI~(Iss^7oABvr?!(hn_a>2N%|hj&sw5lv*!=a-TsurN*<$~9iLZ>qp63C=Zs9I;pt zyegi-`}D?DTtuW=MKtV(P|y@7!vphWvM`I9LdI5Glb-0hG)}*yw4`KLed!ja>qPz8 zEVf65CM|mR)hF}Jg;Wm6l#IQNaBQCf1qrf{j!5;_SX^DCkbYA%ld(k##i#0p(GaKP zQKEoB-<9JYJM4x04pkDpWcWGKw>u=?VXy);14dK@(O-!BxOlPHsi3(vwo48DKpK4& zcY-aA%Cy=KJ8crw+z@4`dCW8~q^pgRJ#xxEu*AtY;&%lFrKP1G+Jvqog!g5OVZAew z@7}eaF)FgtpnYqHGn&{%0r}ZotlZWsRkns-KYtspv&+8?p@I~35@t~5(JQvhHR22B z+vSr)gg1=v!5_OdXLFAf=UyAaA&HOjFB=!3D7d$jT_g<+0$m3nsHR9ChCJ$>)=i3B zj=yf23%(nx8ACPa^KeIyp4rEv?wH@}^MQ8xKt)8bKc0<8gl<_&3D&uJG@ySO*X`)C zkAfoMZkW;oON+@m#ksWWMP6Nh3b7XU{879qCpISNvQ|t2#)tv8M@M$5+_LW;*VlEF zwN%FJW2S-JKb{c1Q+~6#Oi`$uZ8HWw?;01a~A-hQ^d#=jb67CQ8>JF zM%XR*SqEA0GLN zVJ~TIVP|DBE^uyV)VgI<98udP+9ZS^L4vy^9Y}B|xVyVG5D4z>F2UWUao6CMh7c^c zyM++krE!@`-nn;u-&%Km&5t|7sAjj0ysQ(50osR6roa zuOQI#Y2;_X71jVv9S{NtBrPVa=AL!1VxmT}^z6m2aI_{O4S#ONbKj-x;$Ka`1ny zA)y9f{X6+CM1}S2-x=Y1M3Mh|`8-4j`QOQ>Tr6aSe`l|7pHcm1-~f^Tp9bsxeZ}!r zvNL|npo7X;H`Mm|%h)tNpHZV$NW7aQ5tqm4eQ(8QqYY%Tlq`CB1DN#E=!4r4KV*Cx z7$gK8n9<*m+wu1S)GJn_YK0a*vMgcz>j{NmkkRK2@i(<{j5N|JjdhUfI4N*=VBy!3 zpJN>c=jVKh2_XbE%ThAftb`u$(pxfO*V}_lJ+Ihhziam9OClmXy$;VrQyWiGUJrrh z%Yp6l1Vm}TLW|iJ-|B;tpQrUOq$uogm8X}$Iz;>EcW{7J-zUbs|LA?uP@lNb#ZRJ5 zkFVb1wKfs0du42Tp6jnEhqTdmc;|x@bF)3DJIiD<*QVX1nB2dKjX2R}^7+kJrGB#> zZ6Y@>_iU9qfAgh(lt@ zom;i)gB@VV7Zkw9-~B5WzVa)_CB2u@X>;7DwAe6bClbYr{g+!i0NEFzL0q{kS{g~u2Q)ost8Ft11%Et=X5jmakGH=Nyak==1- zCwK;8hWyQ(FFyo--Bt64w!2!Y=0SFMol=7DF?2gE5i&=bn(LJf4hQNr5rx@HH6tYo zSP0pXf<{c~bQu;GbX4imG6*v>2+_pzqcFq@Ue9WzjAU}Q_`t=2Orc|>OX^iJ;xj4< zU_BX)+Pb>&1f=9DbrZ)xN^6_ShROoKaPWu3L9mL2W1SKuR!`qMwQ{8|sBJnu{WMXe zfJrbpg6ec7`7~`82-C8M52H}_ahxG?bdRy z6$XZ@lbPJlUg&d>zppS*9n8q6Ax5`Zz0}lwz!wc)nb|m5iPlxeh@sS6Z) zvn%NAt#opnEW4F-Mk|cf({Z@Zb4AQ!Ki{LLtGUUC6Cw77PByaA7VrA7-^{?zOP54d z73b|+fz%4fs8dWCz%sZKL3>LFZ z{j+3uvL4Ah(;ey>ii98#>dU{i9Q4(Bvrke{|6s8$qC9nfy!wT3L3B2Az25ethpcehj}VdrAGE(hl?d7C1oXLky7OR9A5;fl=7 zYZXZfIuT3R@h^w=rpn)^Nh<{Pq2PhSKYxtKZM9dJ-(O$F#V0n_Y(Nea#Vfu%Z|7vU zRN|GD{B!QX%T;4J*>?Ab2<;n8#@&myo&^9ZVqkIpKaPPok~AG0O*H!SqM8W$p20s9 zRYLd2n>--i_rtBSOU`><#!o33@TJmOLC5=p_zkm}E2S!#UbcQ$lZ|n4Xe452aCs^s z;v-OC5F(00lD>YLv`h;tRhg2u4rO^A6qC0ze%Vn1CHUw&Q#L=p>u0}<0Pu;G@S>?I{ z0|nNc&GPOq4TT56XuJromK$GmB_@yaqqo9Xc!SBazKag0DBNXbvUKe9-{iM_0pv~l z=t`K3)N53GJB=1iGR@uM)!l3-nAy1Naom-7K=zLBO~jr=+6=k}_r8-~JaY zmNCDE$)DjHx@a9|_&J)^*;3(t?RI-F;T4wiDV$Qr#IhheZDAW zaSF21@>IOMCB~A%>KR$WOh^n76Ud)>Vu^`iEiNAZGtu}tvr`9^g>Yf#6zH8HNI>%v z%6peAbIk89QZj{ku=-1_Cx8-}i(s2$Xba}^WHGZ?HO=G9!*8>x;_V%%MXwRkm-T*A zPbDttVndS|&Z-=D!SPFXWG}hV4v!2c6Z?}R6&BKqrz1A|;jr4N1?$~N_LVvj@(XEcGe+b2JC-#m zFt)Htf&>-xG*;5jvCbZTYtwqR3*gt0D1cw@8?@+W2j4kWXYs3b9OPhljbu#Q%@+FB zDd|4Uq>$a|@mEws>fdc|E43xjL`!%*Z1Ka-`bNA^L-O?x_tGug{|0(8nV8*T1-ACf zVQQORztdxGfQ^8H-{bVKpD9mCETIrmjTPSG;BeX*D6;BvT{*!CcQc<)N=LJrwR76I ze9+-2hqT)lI`-CC&|I-`vCVvJ3$LBHl&71e&l%gapS4?6Z`E*$CQsH`hCQVzln|WT zFV#P#dBV&C+|TYCH5Yi$QmaQMHMW`b3~Eh>4sM3d5557!D)f^Q5y)R;baeK%yPv74 ziL;ox&zdu{EBAQ$Tg0AlY8HJ&O!)e#yEy6L3|FmUx*F`L(@57eEi*Hj{^)dPATs7q zN5wFxdwu#wGqy4J*V=R#RC$60I4A^s2Zn1U(DwzM_Oxb({Otr4p|C>h_q;wjHAiGq z3JOvq%<&UhAuBx1Hmik?3CScmd|nHAHs`}x-;c+?YT5~?RcqOP>O2yal;`%?GTO$d z3J*Nr9MVjO9BpZ^%j&x9FJE4+gx+D0*XTDIEosP~9~=NHqM`g-B91v7-iPx(aVdDF z3S_*NT7GbBHnz=IIO*x}b}N0*ea@rl4uwMLaM!0i9PDr~Te2znQMEkrY?WGGRJ)Zj zoh+Iv&S#9!f=%xpqs>ow7BpU^8bwA;G;vA;#>7#Pk!!VgxiFYbs4D~Et6P9c{rK@V zwQ5xi7pqpw8%ST`8QKLcT@x2wRn^4JNYi&<@My->TwEatq;#G81q-m1je~`gK^s!Q ztQ^N~vh_nn{~eH;sjkdp^DXY1X>=N`Y}dx{@?_G3!=___i#<56Cc$ ztoC!WyvX2r2hr&bZ~CpEq3Mh?OirQ}e8wX0Ew z7_P|q7QBAB1L(usV3kJ;fmvtQzV;jLSuk9WPakf0xO0|wqm{yR(=?eI?A&)Rd%C^a zmB9*TE{ahTr%kq8IV^)@tM==)(tCJ@l`%_+(Gc@8Phsvm5?7 zG=+XWk5;V|J^bc+cW6>T-}yGjESOLJg_=&MXt(WhN6t|`b#&6o{EoBdR3Cg&PdHD> z`}Pd3sU&5r=A2I{4OUU`1mI8YxeF~WYC41wKN3K>36@&nuFlTu8$plD2p!^7l_CpO zA&y|G&h9>i3>!n+CI|Q%xT6_OfA5ogY)ts!jGwWHjPXpMC)xtF6mDtf@>i3qF@rAt z_NK9cX|iOx6ez#XefFU?Ng*x>bb*!;1r(ewFbV8s6gjV@(v$=|SY)Db_sPR6nUJL+ z$MQwESbq8Q3(V`s-TJjGqK%m=%G44;x|Joj9Mds_$KA*^GlQq=>tN@(_#wP0Q-)%Z zm2qX&*rT&?zJdJkn+rvubq?MA9Vmd(oS)t5B9|FX#P|UT`bq!rmZPa zGPbmwtZA-0W2TXKwVT<^4~UzXf1rQ=`}c40Hx8X$8*OeD{igLnfuf91?w`iYYII*0 ztUKK>OAWsbq*K+rgNC0=Sjps*=@BJ ze?f`bFUg3;OLR@Y;64L-0tir8xhLoLZZOyNll;y7=3^eI1migco57UJS*`wQHSU?7 z37l<40~m+x^skD!<4T=on~M{K#6C>T*ce1%NSmkCO7V0VHBj=j>hD!+z0(~{WRp)% zw@zCCK{c(Z|IPd1=s~hVv+ASUtQdYG5gwV-MNez7ji0;47{>a{L3sdl~=y^E&c z2h%{8-)}SOzeUbZYBuBiD@0Ixb4C->!O1e3RZC3{`?fsLT8EMROPfa)8yL{<<9#xD zok_0OH&TnM+U-`pR(j67>PM3&;P?|;E@`4*zSiHE>9*+@@;WD!9l#Yz=F(Rne*}x= z+|gvG&pR;gF9m~CT~_6iBAki+RPJvu@s=yS7J zYBI-rMPBBr($sL30+(%fvnYv2O1`0ehc$?IMl+_*VZC~DU$j3{p~2_i9vbdo$tz7A zRO_>G*7e+T@Xr}M)6HhdgEI7Z=OJ{gWK5qLIbS_eLVHCcfpzLR$C~Jt9?Z8y_?&T{ z^RS_*j?cw-TX2f9ck;*m#e`mw&$l;k3V2*8*e!1Tx~THf1xxAQVU@=sW}=hEIEwVH z+{N&D-`{W07_8MGAOx&EJny*kMR$L=?4BWUBVaNRbe$v(r2K-h2TgpA0P-i#+Z-H= z27z)Vv9Vut_siX#vh4t~Y?)H`D`XqgJ4kA2^~43hOcg0=CWI~kb60LLVq*c4YcajE z{=DIs`vZ5YRvB@lrJ4inZ+jp^F%ESq^?A5fnj!%r{?TiFubQEv?tu|eTAF}qEvwmF z-P>E_NkETaEGK)0zo)m?E4jwX&hBn&04`@+XW9+u9WES1<#y86$J-@^RT5+mBpnDpI_Sj1>${k$nrL%WGMORpNF3+j9T z_#J2Zw|O9yrvwQ0+n2ukZ06Pgfrddx%8Q)XG6Ywytc2PbpAX);lg+~dMl@zwO~7wh z%dL|o@P<-b?Jd^;@ukB#=2!*AS1EIzym&ei^d2;=?=^BTR*MqJvv+YIXEXYA#dug( z+YQh=b<5^o=NqQ-@Yk@>DBR)4fCOA3uH{3uFLnqV%uA zA#4DK`XVW&KrSdUQ_iMmaDTs|7y0Ovl~D7zw#T^(h==vwZaOjYu&9XYsg@vsT>b7{ zmbr6RuYlwwxeZwV17Ud`HyY$OCsQ~AIg&f&V!|rnlN>04E$*Ivy=Nwc#%e_tutN%_}!f7Tw82CumlMtwEDqYG1@zb_qY#`P~X51*eS>t6z^W@i8U1>%qUAYe2?YQB~KT$KZ^vLXKP4m9XT0NK;@s6GdQ+(h((gwkrp zQ4m3|aE^RiY(XHvW7NRC{-%N;komLVzj5ALFN?PW7Ed}!2?D)PbOUSyv0^Gl-v|7c zfS==+zeE5{uW#%BKVN(M3#ElJXOn|KM{(|!AkZ(bMn=FDPK3Icz|7)*e|Dm$!}VWs z^~bls)u6cj`{y0#q&!;ylW1wmJszh4Gw^W;UESQ?0ND0vBL!->{@NlH6;V2|WDMqX zEUcp9D4a!xYYMFI1CZ`8hqH^DJIoyG`ER21DN65vUv~xuZu})_wUGa^_5Dm(MNF*l zSo0~6*9uTl?grkSwrh=w@3CJavTS!2>6=uGq}>vN%1qpj0N1WGqBHEK)_k8WXD_b7wa-|cSqU*o5T?UyE| zPHR_+M6ddwUYK8^(oV*b=PI?P%f%B(b65fW@hV*o|1}Yum;K<}Z0(9dP|)v`hn+qv zBE(9AzOJ?@VZeAKgZ=*T!K~!yp+dc?#c|;odFtKR%H#b_BxP6!tZiv&p;+(scXAzq z?6$(;H$K@vBTLWx`2vK}XvWW8I<5dT05K38KxqNLWl*J#DxNU^ptxDR($>Jm!Ln}u zkshConzLGB#T4e{oX-C_K}2|PleHDXhz0KL>t!Q+6-WsXv!|WWO|gJn?S00#UoZ#- zOyc4n(NSW1f9kqh3Nh{p zx?d!kjp3oHs2}$mv%>ggr>j)!#T+nY(a>5UhNhMxSi$LKWs#BRxd^a1+xf+cwXaa! zSskr3tF@a@MFVqoo$VN#Nxj=YyH0_WJPkJCn7C)pvWY!j24D$cBKqES9RHdCdTuh^ z2Akdb+4%U^&B*QA1#4Yh68KDZr@K6s7<7@toSisG_%ri7qVS139|?(-F8v?y%XoE>@D&G$kQxnMAF%%8eRhUL*hV>5lZTiA?qm=M@Vv=g2umE^*!!6K0Ox; zOSXVVR%>f|eEdWbk<;5Z1x+>(8>4*h(u(Car?sA*76}fUWUur;2Y;lndXC4L;Xg`C z&CAsG4sW&%fpX(1lG|0;i~i_(u$tpaMy|NPlcZLtLB(mSWp_9zUeK?2$mKqLqZ(R@ z|BjZ5Do@jHxm>*<<8=-NF6m^k`uTNma1WxenA-Fz(3sZ5PBXW_Nvk3_PW?f*!Z`15?{GS4yoaH$YC9ZH$j9h>Q4!mZu{6KBZ$L!<qv;a*JgY7N!#Pd%=z8@QHZ`zz9gYqr)PE7ej3Bsvp487&}X%Z_#P56lswIKh;kO z;(*3q04<(pZ?G{_)pU5{{3dg*=9eS4lc~bdeAU`dmc&@HMRAvr98Ql)IqJ`>>5DnD zMAB|a%J5YE?02@S9g+!Pj{Fb8Saz#E_HItSVE{n#?DSS}z3r1z(0#n`-FLo&%cry0 z4MutF9sox`xWDbKM0$C5)a@~`c|Y8b>9tr}SWL7_rZ&jpIhs)R0Qa`N0@9H{x@b}| zHfO^8f-G}RX`{6j*G0eX(`6V-;+);0fZ+jhAmxrTPi7UVq?(?$$g|XL6XqRmzgtg_ z6ge50M?BmEmFaX!%Csbw;1ZGHC8BC)cc33> zpeLg~-(m>sL*KVZXAh(d)APc(RV!_^-7pw?JovDwo*+>G^SO%m^td~E9wMS#Au*G6 zOaua<#)Y(ULlt6U!j+jwnT_S{7=2bd?&6B{28_=b$(A@Rm72X$V`^5v$rAZ)F*&wo zb$u$;$pHCdh@b^l^H7g3?ynjnmfV7E>FE}yMn{S{oz_cj+`?F?HLJhu=l@c=Te|ljd&FJJN!lRI|e@^DIA_`BJ>TjOSWP+vp`f2#w zFBp@o$rnwXcVBlZfBnWOBuzXK$EUW(f{VHN1Kd6G#{!kX1c^e z9L?#~SE&E+rAnuM_h$fExnnCN00T%{3?Nzo z;LG1#92D20;JvUwWY`WG#6q0N)jx#Y#R8(`=q7RwJG{_mDTUih6ezB>HwF3q3R%8~ zeKy3=tEIwveZ4q{h8F3(svylWwTabH*W<^xp)uL?dk1Qrwug`f1`y~^W9d%X${hwuNdEDf zUx(L|#X+ed<}qaPt7%zc;#*;?WZINOnIa%%-Xi~=h2BvJ`VjGbjZK+-2j81Ld%6&L z=uDPSw8l7D#TQ5nn_Q&$A~ngp1+2z_`}fZj1*K5$XV4W|JP$T%{G&M>(wt7xbu6} zw&_#Rb`INL$+SrY6XtG5{H2iE00U$P0ER>Je@E{Bjed;0L8=5nIugCHvH)*&6rDJbSOIrN+S9^obnq5Xq zrJBnG+}@is(k3GZx}WY8->wSyTtz?Ekc$R|pw5#0thRW`l)8;pDQCq2q}&V9pD$1dId^J# z88S+`*6BD>%zuZO2wuq3tC%eM#y-o!p{&5+us0mC&vhI{$a%6=K(@|46z)YEi%5&ggU`{je$VtS3_b6Bf&W+HG(MqBN7bw1Q2_;^~3aylISNKSVm zm?>Ab84l}WCzhv8wq5Ah21pY?`e_r+NSKQp3T}nFs5mM#tMnTk9{Go<}4&P;OT;eTy`LjGop8Ml(m;FnAMgy33kz>e1dov*ki838CVHCU~ z^Iwja2O{Eims?Ye0dNW^r4_B5rwRE+s^*iFg z1HM6$KuZ)}nD{l5$+6$e=XEO67#^rrx<#e-WH;HsP23H!y2iu(W_d^*ND0x)I$cND zVVwq29-V70za-{>Owgm)PbgOkY*fEcGNwP@V58~oZu}&`U#vh}uqxCbU2q$EWp^xS zeDQ02gF1>#z*$M~kT+Edj3^u?{(EYh@$nvSx>&uw_i1F7P~?k3$ygRoo=7Vz0h0j* zi?dKROc@0FNgXa$XR&1aRv;EU0D%0!rsH{1%vz^tx9gFB{r$9dxEN2a-Pi}5ekIY^ z+oUrcnD+#r7}~CgCINM5#dQQYX{JI|wNbQIN^V57$eZ{U zn-YCMYAN3-JW%Ev6_== zuT%U`BkI)*-(mZ6Iz{kscQL-o0RFJ4X`Cn3VBB?Z>te-WvrJjQk;!E<{mr5P;`z6! zWzJi>LLm2BRb{H(1Wa23#-9uVDFNoC2!)v&8#h?2@I^~X0Q%|jc#++s?;AjTa1dF% zrToZaOLQ{cOWDLFRG01$!c!rPCGr);nazhREG(|Bv?{gU3%1au_SV*4Y)IoK>~NxC zN?F8#!JLjsRFv3v$oXMx*<9bUG&3up?Q}X0Q2byAbYJIY z?8tXODaFYYL3tXKbA~S;`p3xHFXeSbk(DT4?fu*NsK<$# z#y?*Nz5whV5_ziv4J7{Y%s*!s;k*5>nADOuBm^Of_0=NPe~!Dr?odYXfBZov=l1uh zK4^!+JM|gJC-g2YMu>9Q&Yb@R=nK*n3=(Rm3{v{|%eENpKewTD2qFj>272iPi2iqH zdp;K|dM>mc#H-u&FAU?D*Q1D_5WFM17s&ss81VaFs)3YR))_UG{y(B=g#Yg)RsWc# zLR8e4>5jnqM0sB*8VJ|dx3)3?9|#s&P|&}0&>jk~m=gmc^*2z!=eS1FH2efAkbm|+ zQ<$B(!vlAZQ1QM1ocmeO;9mn8AaX$-ZHhp!(f&D(S;>QVNl6_UluMu_3XJ(F?5qSE zQ#}OqkKeMxJWsvXM#)sd#qNqcZVnb2PB(nLpR&8@+|PF6gS=cL#ol;hgFrXbf=b=jD1=jGovZWjU4Cofe+h%9nkM3YKQf=G~r) zr6}lA+?j1xRH*)S`ZiS5?}-}O=yutIPe9O%x7BNZt8yT*4gh7-k!=S31Xwo$Xs0o7 z&{Vi9p3oB)B=nLBYcDZ9ks1{nN7mDe`y+mBV|{&H6ALTrZxSL04?gO|0V&~xLcS0= zY8+}nn$WyML7a$JUSBut3<~lQ^kZD#<+dGEgRdaHeDSWQKRqq&?An_%bHtc6v(Zu; zrctFXix1e}WE=EF-D+CR!0)a&y4enLywGg0P0s%gyB0#7DpT|(UMcSVud|jI@hsLG z*FfLLWta;Z{3^>rwfUI$lci}&^pKHd`Et2>`{mTu@Wb{X=FxJxfCFD>_ds6&kdb;_ zv(r>K&pb=|nas;vawhXsc33_Wr)qYr_INZ1tQDe3qzLspBAl2Om_H@Zv`%&&UJQKp7jB z#HfK3<3J9+HU80H;h3J)zwPvqlrywjPPk-BG%)bi?U*)oXwGg;yUkkL(u6fcBrmQA zD^kp;_C5E9bi2azmW*Z#-6{N8eDv(*(k1;rv%C3{FFtYupCuOoW{d&E>#r-r}12FRz=S&z_DP2>IyiJ%FwFqU%s zYs{lnD4**^UE_XqR8*a_wpJzmlbu|_R9)M@SG|GK6e?AOKvpL4X>lWZ&$q(F@-3XU z$^m_NUYI+zD_4A`%E`$&YTm3cItm=SjQJCjC<^Sv3??~pf%5k=t;|lr0|Tmk(H`e# zMT+UH=PC3BQ^{{h9-5tRk{mwk^ZD?6^IJ_OwYXgYkgN|gfF?&2H23vd7>?VI6V7$MwoZ5H0?>%GtKURWV{dM`)>V#XpgQ9C|d zY}@Q^cZ0THna@d0jQ18!hY0Nsj&U znMybVg~PQDht2!lr)4Ur0s*+EMwdsB9V~U1@zTwj$Hm;v-8@W=8V&Qo=k{mE z=ewi_CJm-E_|{EgTB%gJ+Ku}gX%*_^zCJOS7u@>hn?<2W-s1BzB`^wNXOJJv`}X8d z9{)LY^xjJGdeZ9$Uqs&`jgz~3DptOR(oD%&rdJ>#)4VTAo&4Y2+@7kTcok}79ESOQ z`TLqRkWTNL?JJ7?CR;X6mYeNrY#i*2xr)T2{9T;>iq0%nSGh%(Ee8F`Qf;ThPyLTvVdHph*oR1zZ*hU zb9^6j);eE&1Ee1d8J@hBItDn3wfU=7ql1&g!*j_fvK*`&d%%36!T3+J zPfq6Y0y#`r$VunDvPCrpe1e`Q-y=&DU&fLa?M;{^s7$rkZbryk&7DL?NhPRZP$#Mg zr@B@WC--9>)o6Fnvx9qjrW-!b*+yq@M#e<^K6B4Xa&Ra$F11#En;70}6tARJ>zF*i zW(pPXoxVEXcIXaGsnDvmiq)U7nk(iJEmp5FEC{VxGu`}+01~2#FIudRy3MLF;CWi; zZnqd}uGVgI8Bu{Ju;S4&>353WDYHgEKbW1&(eb{N1dc$;dmq6Py{pG6E3ovX4jR70A{IAn%t7hBg+Qyl>fj{Z>`2}xs z_`F*TX9iKG9w*!aWgr96(oQ9cK4&L+qVaFgQff-qN^31cCN~FN_GTX@g6>`$8ls#I zdUE3-y@fudX7hV^+m9V_F&SVZuB(?%`weNx6xFuUM^EbGabvKaA-tW_Ta)(OJXVL^qL?WTTeS%sVZ}3><+iXV-$kZHseYK?`VU%2)RsXIci{P zwC|)6MnB0BvE42t_FG&z9bZ;)tCsxSbmYvO^7|P_?sHdUGgtm1sB4GU+gev1q8Z9R@%?o$Fzx)#zM<>-Pnk;wj_1L;?rk zgian3+$>aG)5G^C)eI@KoG!!`kFPvWrzZwEEOoVXwtu5M z_XjBDG~0+yqvz8lI=opVPe}@#zUI2{cB+`#+&> zcKqMGtlBaGZ|!X>w?YJ3bgkgxdYLo%k2}j&-)GKCiW#(otGI2ZzweZWp?k%=qXq*) z&*An5-U6>K$ln>DTj|7&`*LZVD_yl=Aw@;CtQw~a)|>zk74i-5X;xWVTYJMFyBkPG zGg%(CX6T+qvN}Aj^cfTdfC{S+;fb9&uvqC3-M=K8zO#0}yDn^}hANcQFVJ%?&G� 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≦d`s>8@Ra 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 z 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

XyW`~Sx{bkHvb9+Xv170nV+$_ z2}3Wy&GKMJ3}A(Mnz#z3Mu1UDA#aruO$I_fqZRAbyc31Qnur}G${c;SDaaa$om~?( zMDYDUT06be#g%-7K1BaR(MObotNG}ZZqK82q_&6l*qzcOl14@|iZmrhc;Qa8?)b^r zgVovV=YH|8q!TVLYnR?O$}UyU=VrG)t?=nBk3R$bg*wu^!FXZJb$&7LDOY@`(dp;{ zHVGTg5<0$&3Qm2Aan>l=MJ*jwbT_97tcg#g&-B*rv=p>_o?kLrv0kx!K1*UmU!b^pAm6%z%(jN4|Q-YKIUsi>U+L!m4T7|+Yyu^XivxhHp)~rFB zm=7FHwSLE2^`d5zI0s?vImtzDr?&P7=puLWll@yoy%tIrT#ku8kW&@L9Hf*U@-}Mh z9lq(I`Am+?z$#!~l6h)5u6Giv;9+`Ut@_HR2qsowH;rX@6A4B>OpZ0wpDIMb-wbZ- zB*Rl&&zXLe>&=C5=Nl%N?+?gnTJTu4Td(|Psr`z7Abvzk3#HVB*-hH|7NStWmTu4r zh-$v`*e`QNPE*Lw8V`5Z-Q$A#w1N@wyi5?GyW)SQ1a*ie4%r;I=C%cZj5nc5rsBsu z-#$6-K58V1ev0`99^HOG%df_VoCRcJeN;JThcl1HS*oA5`)fT#EoU4{Klg>y)@qEr zjwm4X7|sYOoX+vEqL0%hq`Ds4e z^E+w2nNEWuSHwJ?#W(J&dv+sU z56d@CX1?6MYwKlgI`>bJLN8e^EbXXqg62ANFhSITl%CpnU7V_)qfa*+UAn?>_jJHP zk)t1DV+`iYJwP{6h<77Sl>6Ocg+H*&t`&HdB0BIhUj7`NI*KXMFx+^1>E!-6^Cj2ZX|g{+}^F-IpY^3Z-sMf{U!f* z%l{klM=QvID2#qG0RSZGumJSmNZZga@7sCnHEKJRku8n19P5XFs4#&tC*nNF;tRFtq+-FvN+xuJ45d z0Gj9l0G!|Sfd0Sq&gLdICXks7BxU)NEz|sXXY(OaI7lOY?+AAz0DzH;le3+Trj0d| zg|msx9|3{`ThlEN01X5{`db~wNBaa83E=+#ag9ys literal 0 HcmV?d00001 diff --git a/docs/移动端任务与账户平台需求文档.md b/docs/移动端任务与账户平台需求文档.md new file mode 100644 index 0000000..c94380c --- /dev/null +++ b/docs/移动端任务与账户平台需求文档.md @@ -0,0 +1,368 @@ +# 移动端任务与账户平台需求文档 + +**参考站点功能拆解与重构建议** + +目标:在不复制品牌素材、敏感数据与高风险业务表述的前提下,复刻参考站点的信息架构和交互骨架,并形成可开发、可验收、可审计的产品需求基线。 + +主要读者:产品负责人、交互设计师、前端开发、后端开发、测试、风控与运营。 + +观察日期:2026-10-07 参考环境:移动端网页 文档版本:V1.0 + +结论:参考站点由账户、任务订单、余额台账、储入取出、会员等级、内容展示和在线客服七个域构成。建议复刻页面结构与业务闭环,但重写资金、隐私、安全和合规机制。 + +# 1 文档范围与方法 + +本需求基于登录后的页面逐项浏览、按钮与导航行为检查、页面状态观察,以及浏览器中可见网络请求和前端资源分析。本文把结论分为三类:实测行为、前端可见实现、建议需求。敏感手机号、邀请码、银行卡等信息已脱敏,不作为原型内容。 + +## 1.1 已覆盖范围 + +- 登录页现状、注册页字段、会话与路由保护。 +- 首页、荣誉榜、会员等级、平台简介、规则说明、代理合作、公司资质。 +- 订单记录筛选、领取中心、订单资金变化、手工商城。 +- 储入、取出、收款信息绑定、储入记录、取出记录、账户明细。 +- 个人信息、头像上传、登录密码、交易密码、通知记录、在线客服。 +- 可观察接口、响应结构、关键数据字段与前端安全实现。 + +## 1.2 未执行操作 + +为避免生成真实订单、变更资金、上传个人文件、修改密码或退出当前会话,本文未点击自动领取、订单提交、储入提交、取出确认、收款信息提交、头像上传、密码提交和退出登录。对应需求由页面字段、前端接口定义与上下游数据状态推导,并标记为待联调确认。 + +# 2 产品定位与角色 + +产品可定位为移动端会员任务与佣金账户平台。用户通过注册、储入余额、领取任务订单、完成任务、获得本金返还与佣金,并可查看台账或发起取出。平台通过等级、任务数量和费率管理用户权益,通过客服处理储入、异常订单和账户问题。 + +| **角色** | **主要目标** | **关键权限** | +| --- | --- | --- | +| 游客 | 了解平台并注册 | 登录、注册、查看隐私政策 | +| 会员 | 领取任务并查看收益 | 首页、任务、订单、余额、客服、个人设置 | +| 高级会员 | 按等级获得更多任务和更高费率 | 等级权益、额度与次数受配置控制 | +| 客服 | 处理储入、取出、异常任务 | 会话、工单、用户与交易查询 | +| 运营 | 管理内容、等级、任务和公告 | 配置、发布、审核、数据看板 | +| 财务与风控 | 审核资金和异常行为 | 充值审核、提现审核、台账、风控规则 | +| 管理员 | 维护权限与系统参数 | RBAC、审计日志、系统配置 | + +# 3 信息架构与页面路由 + +| **一级模块** | **页面或路由** | **核心内容** | **入口** | +| --- | --- | --- | --- | +| 认证 | /login /register | 手机号登录、账户名、推荐码、验证码 | 未登录访问 | +| 首页 | /home | 活动图、快捷入口、余额、荣誉榜、会员等级、公司介绍 | 底部首页 | +| 任务 | /receive /orderDetail | 领取、等待、任务详情、提交完成 | 底部领取 | +| 记录 | 记录页 | 全部、待处理、已完成订单 | 底部记录 | +| 手工 | /mall | 手工任务卡片并引导客服 | 首页快捷入口 | +| 资金 | /recharge /withdrawal | 储入、取出、余额、交易密码 | 首页与个人中心 | +| 账户 | /mine /personalInfo | 用户资料、信用分、邀请码、设置 | 底部我的 | +| 明细 | /depositRecord /withdrawalRecord /bill | 储入、取出、余额台账 | 我的 | +| 客服 | /customerService /iframeCustom | 客服列表与第三方会话 | 底部客服 | +| 内容 | /html/{id} | 平台简介、规则、代理、资质 | 首页公司简介 | + +## 3.1 建议导航原则 + +- 底部五栏固定为首页、记录、领取、客服、我的;详情页隐藏底部栏并保留明确返回。 +- 资金页面必须显示币种、可用余额、冻结余额、预计到账和手续费,不使用含义不明的“人民币编码”。 +- 所有金额变动都能跳转至同一业务单据,并在台账中保持可解释、可追溯。 +- 客服 iframe 需明确第三方来源、隐私边界和会话状态,失败时提供备用联系方式。 + +# 4 核心业务闭环 + +## 4.1 任务订单闭环 + +1. 用户进入领取中心,系统校验登录态、营业时间、会员等级、最低可用余额、未完成任务与风控状态。 +1. 用户点击领取后,系统创建或匹配一笔任务订单,返回任务编号、商品、金额、佣金、截止时间与状态。 +1. 任务确认时,台账先记录交易扣款;完成后记录本金返还和佣金入账。参考数据中同一业务单号出现 -298、+298、+14.9 三条流水。 +1. 订单必须支持待领取、匹配中、待处理、处理中、已完成、超时、冻结、取消与失败,并记录每次状态变化。 +1. 任何补差额、冻结或信用分变化都必须有明确原因、复核入口和申诉渠道;不得依赖口头客服指令。 + +## 4.2 储入与取出闭环 + +储入页面按等级展示金额、赠送额、每日单数和佣金比例,用户填写金额与姓名后提交。参考站点还要求联系客服获取收款方式。新系统应改为平台内生成可核验的支付订单,禁止客服通过聊天发送临时个人账户。 + +取出前需绑定姓名、银行名、银行账户与收款码,随后输入金额和交易密码。新系统应只保留与实际支付通道必要的字段,敏感数据加密存储,收款码上传须做病毒扫描、内容校验与访问控制。 + +## 4.3 会员与代理闭环 + +参考站点设置普通、白银、黄金、铂金、钻石、星耀、至尊七档,充值门槛、赠送额、每日单数和佣金比例逐级上升,并存在三级推荐奖励。建议将等级规则做成版本化配置,禁止前端写死;代理奖励必须在合规评估通过后再启用。 + +# 5 页面级功能需求 + +## 5.1 登录与注册 + +| **页面** | **字段与控件** | **校验与行为** | **验收要点** | +| --- | --- | --- | --- | +| 登录 | 手机号、密码、记住密码、登录、去注册、隐私政策 | 手机号格式;密码非空;错误次数限制;登录成功进入首页 | 不在本地明文保存密码;支持会话过期提示 | +| 注册 | 账户名、11 位手机号、密码、推荐码、图片验证码、隐私政策 | 账户名必填;手机号唯一;验证码校验;推荐码可选或按运营配置 | 提交前明确同意协议;防批量注册;注册成功自动登录或返回登录 | +| 会话 | Authorization 令牌 | 401 清除会话并返回登录;403 显示可解释错误 | 支持刷新、撤销、设备管理与退出所有设备 | + +## 5.2 首页 + +- 展示轮播图或单图运营位,支持后台排序、上下线、跳转类型和有效期。 +- 快捷入口为领取、储入、取出、手工;按后台开关显示,关闭时给出原因而不是静默失效。 +- 账户卡展示昵称、可用余额与币种;点击可进入账户明细。 +- 荣誉榜为脱敏昵称、积分和结果状态,要求数据真实性标识、刷新频率与隐私策略。 +- 会员等级展示当前等级、费率和下一等级条件;等级图不得只是一张不可访问的图片。 +- 公司简介包括平台简介、规则说明、代理合作和公司资质,内容由 CMS 管理并保留版本。 + +## 5.3 记录与订单详情 + +记录页显示可用资产,提供全部、待处理、已完成三种筛选。列表项包括创建时间、商品图、商品名、商品价格、佣金与状态。分页默认 10 条,支持下拉刷新、空状态、加载失败重试和详情跳转。 + +实测发现先切换待处理、再切换已完成时曾短暂出现空列表,而重新加载全部后可见已完成记录。验收时需覆盖筛选参数、缓存状态和并发请求覆盖问题。 + +| **状态** | **用户可见文案** | **允许操作** | **系统约束** | +| --- | --- | --- | --- | +| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 | +| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 | +| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 | +| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 | +| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 | +| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 | + +## 5.4 领取中心 + +领取中心包含会员等级、佣金费率、主领取按钮、匹配进度、今日战果和规则说明。点击主按钮前需完成资格预检;按钮点击后立即进入不可重复提交的 loading 状态;匹配成功跳转订单详情,失败显示可恢复原因。 + +- 预检项:营业时间、账户状态、信用或风控状态、最低余额、当日次数、未完成订单、等级商品范围。 +- 统计项:总资产、昨日佣金、今日佣金、今日领取数;所有统计口径需要后台统一定义。 +- 规则文案需要 CMS 管理,禁止把风控与资金规则硬编码在页面。 + +## 5.5 手工商城 + +页面以双列卡片展示图片、任务名称、份数、工资与已领取数;点击卡片实测直接进入客服页。建议增加任务详情、适用人群、交付方式、预计周期、库存、审核标准和风险提示,再由用户主动发起咨询。 + +参考站点一条商品名称中可见脚本标签文本,说明内容过滤不足。新系统必须对运营内容做 HTML 转义、富文本白名单、图片域名白名单和持久型 XSS 检测。 + +## 5.6 客服 + +客服页展示多个客服入口与工作时间,点击后在站内 iframe 打开第三方聊天。聊天页含历史会话、系统消息、文本框、图片与视频上传。需求上应增加服务状态、排队人数、会话编号、隐私声明、附件大小与类型限制、敏感信息提醒和会话评价。 + +## 5.7 我的与个人信息 + +| **功能** | **需求** | **边界或风险** | +| --- | --- | --- | +| 资料概览 | 头像、昵称、邀请码、会员等级、信用状态、余额 | 手机号等敏感字段默认脱敏 | +| 储入与取出 | 提供余额入口与状态提示 | 资金操作必须二次确认与风控 | +| 收款信息 | 姓名、银行、账户、可选支付通道 | 加密、最小化收集、修改留痕 | +| 头像 | 上传、裁剪、预览、替换 | 文件扫描、大小和格式校验 | +| 密码 | 旧密码、新密码、确认密码 | 强度、频控、成功后令牌失效 | +| 交易密码 | 独立于登录密码 | 不得用无盐 MD5;失败次数锁定 | +| 通知 | 公告列表、未读、已读、详情 | 重要规则变更需留存阅读证据 | +| 退出 | 清除令牌和本地用户数据 | 保留确认,避免误触 | + +## 5.8 储入 + +页面展示等级规则表和金额、姓名输入框。参考规则为普通 500、白银 1888、黄金 10000、铂金 30000、钻石 50000、星耀 88888、至尊 188888;费率从 1.8% 到 5%。规则说明和表格存在个别口径差异,必须以后台版本化配置为唯一事实源。 + +- 提交前展示收款主体、订单号、有效期、金额、手续费和风险提示。 +- 储入订单状态:待支付、待审核、成功、失败、过期、取消;失败必须提供原因。 +- 若需要上传付款凭证,必须经过用户明确选择,并限制为图片或 PDF。 + +## 5.9 取出与收款绑定 + +取出页面展示余额、金额、已绑定收款信息摘要和交易密码。若未绑定,弹窗提示并引导至绑定页。绑定页实测字段为姓名、银行名、银行账户、人民币编码、支付宝收款码和微信收款码。建议删除含义不清或非必要字段,并根据支付渠道动态展示。 + +- 金额校验:大于 0、不超过可用余额、符合单笔最小值和最大值,并显示手续费与到账金额。 +- 状态:待审核、审核中、处理中、成功、失败、驳回、取消;用户可查看原因和预计时间。 +- 交易密码只在提交时使用;服务端采用强哈希校验并配合频率限制、设备风控和二次验证。 + +## 5.10 账户明细与记录 + +账户明细需按业务单号聚合展示变动类型、变动金额、变动前余额、变动后余额、时间、状态和关联单据。储入记录显示订单号、状态、金额、创建时间;取出记录字段同构,并增加手续费、到账金额与失败原因。 + +# 6 数据模型与状态机 + +| **实体** | **关键字段** | **说明** | +| --- | --- | --- | +| User | id, username, mobile, avatar, status, inviteCode | 用户主档,手机号单独加密与索引 | +| Membership | levelId, rate, dailyLimit, withdrawLimits, effectiveAt | 版本化等级权益 | +| Wallet | available, frozen, currency, version | 使用乐观锁或账户序列保证一致性 | +| LedgerEntry | bizNo, type, direction, amount, before, after | 不可变台账,支持幂等与冲正 | +| TaskOrder | orderNo, productId, amount, commission, status, deadline | 任务生命周期 | +| DepositOrder | depositNo, amount, channel, status, proofUrl | 储入生命周期 | +| WithdrawalOrder | withdrawNo, amount, fee, netAmount, status | 取出生命周期 | +| PayoutProfile | realName, bankName, maskedAccount, qrAssetIds | 敏感字段加密与脱敏 | +| Content | type, title, body, version, status, publishedAt | 公告、规则与公司资料 | +| SupportSession | sessionNo, provider, status, openedAt, closedAt | 第三方客服映射 | +| AuditLog | actor, action, target, before, after, ip, device | 资金与权限操作强制留痕 | + +## 6.1 幂等与一致性 + +- 领取、提交订单、储入回调、取出申请和密码修改都必须使用幂等键。 +- 钱包余额只能由服务端台账事务修改;前端显示值不参与结算。 +- 订单状态转换由服务端状态机控制,不允许客户端任意设置。 +- 重复回调、网络重试和并发点击不得重复扣款、返款或发佣金。 +- 所有涉及金额的接口返回业务单号和最新余额版本。 + +# 7 接口需求 + +下表分为实测请求与前端资源中可见接口。实测接口在浏览器中返回 HTTP 200,响应外层为 Base64 字符串,解码后为 JSON,统一包含 success、code、data、msg。建议新系统直接使用标准 JSON,不把 Base64 当作安全措施。 + +| **方法** | **接口** | **用途** | **主要请求或返回字段** | **证据** | +| --- | --- | --- | --- | --- | +| POST | /wap-api/doLogin | 登录 | username, password;token, users | 前端实现与已发生请求 | +| POST | /wap-api/doRegister | 注册 | nickName, username, password, inviteCode, code, uuid | 前端实现 | +| GET | /wap-api/captchaImage | 图片验证码 | uuid, img, captchaBol | 前端实现 | +| GET | /wap-api/profile | 用户概要 | tel, username, headpic, inviteCode, level, levelRate, balance, xyf | 实测 | +| GET | /wap-api/banner/list | 首页横幅 | 图片地址数组 | 实测 | +| GET | /wap-api/home | 首页开关与内容编号 | SHOP_STATUS, TASK, POP_MESSAGE, 内容 ID | 实测 | +| GET | /wap-api/user/data/home | 荣誉榜 | name, avatar, balance | 实测 | +| GET | /wap-api/ctrl/level-list | 等级配置 | name, orderNum, num, giveNum, bili, 提现限制 | 实测 | +| GET | /wap-api/support | 客服列表 | qq, url, workTime, 分页 | 实测 | +| GET | /wap-api/convey/page | 订单分页 | pageNo, pageSize, status;订单与商品字段 | 实测 | +| POST | /wap-api/convey/order | 领取或匹配订单 | 无显式参数;返回订单 | 前端实现,未执行 | +| POST | /wap-api/convey/order/submit/{id} | 提交任务 | 订单 id | 前端实现,未执行 | +| GET | /wap-api/convey/count | 领取统计 | balance, countToday, countNumToday, countNumYesterday, content | 实测 | +| GET | /wap-api/convey/unpay | 未完成订单检查 | djs 等状态 | 实测 | +| GET | /wap-api/product/page | 手工商品分页 | 分页条件;商品卡片 | 前端实现 | +| GET | /wap-api/platform-config/trade-wait-time | 任务等待时间 | 秒数或分钟数 | 实测 | +| GET | /wap-api/bank/current/info | 当前收款信息 | 收款资料摘要 | 前端实现 | +| POST | /wap-api/bank/save | 保存收款信息 | 姓名、银行、账户、收款码 | 前端实现,未执行 | +| POST | /wap-api/recharge/recharge | 创建储入申请 | amount, name 等 | 前端实现,未执行 | +| GET | /wap-api/recharge/page | 储入记录分页 | 分页与状态 | 前端实现 | +| POST | /wap-api/deposit/do_deposit | 创建取出申请 | amount, paypassword 等 | 前端实现,未执行 | +| GET | /wap-api/deposit/page | 取出记录分页 | 分页与状态 | 前端实现 | +| GET | /wap-api/balance-log/page | 账户流水 | 分页;类型、金额、业务单号 | 前端实现与页面实测 | +| POST | /wap-api/users/setPwd | 修改登录密码 | oldPwd, newPwd | 前端实现,未执行 | +| POST | /wap-api/users/setPwd2 | 修改交易密码 | oldPwd, newPwd | 前端实现,未执行 | +| POST | /wap-api/local/upload | 文件上传 | multipart file | 前端实现,未执行 | +| GET/POST | /wap-api/user-tc/select /read/{id} /page | 弹窗通知 | 公告、已读、分页 | 实测与前端实现 | + +## 7.1 新系统接口规范 + +- 统一使用 HTTPS、JSON、明确的 HTTP 状态码、traceId、错误码和可本地化 message。 +- Authorization 使用短期访问令牌与可撤销刷新令牌;敏感操作增加一次性挑战或二次验证。 +- 列表接口统一 page、pageSize、sort、status,返回 items、total、hasNext。 +- 金额使用最小货币单位整数或高精度 Decimal,禁止浮点结算。 +- 上传接口先申请受限上传凭证,服务端扫描后再绑定业务。 +- 所有写接口要求 Idempotency-Key、设备信息与审计上下文。 + +# 8 管理后台需求 + +| **模块** | **主要能力** | **审批与审计** | +| --- | --- | --- | +| 用户管理 | 查询、状态、等级、信用、设备、邀请关系 | 冻结与解冻需理由和双人复核 | +| 等级配置 | 门槛、赠送、次数、费率、提现限制、商品范围 | 版本化发布,可回滚 | +| 任务商品 | 商品、图片、金额、佣金、库存、上下线 | 内容过滤和审核流 | +| 任务订单 | 状态、倒计时、资金流水、异常处理 | 人工改状态必须双人审批 | +| 储入管理 | 支付订单、回调、凭证、审核、对账 | 每日对账与差错处理 | +| 取出管理 | 审核、通道、手续费、失败重试 | 大额与异常规则 | +| 内容管理 | 横幅、规则、简介、资质、公告 | 预览、定时、版本与发布人 | +| 客服管理 | 客服入口、工作时间、会话映射、工单 | 第三方访问最小权限 | +| 风控 | 设备、IP、频次、关联账户、异常金额 | 命中规则可解释并可申诉 | +| 权限审计 | RBAC、操作日志、导出日志 | 关键权限定期复核 | + +# 9 非功能需求 + +## 9.1 安全 + +- 密码使用 Argon2id 或 bcrypt 加盐存储。参考站点在浏览器端对密码做 MD5,这不能替代服务端强哈希。 +- 输入与富文本做上下文相关转义;运营内容采用白名单;CSP 禁止任意外部脚本。 +- 银行卡和手机号加密存储,日志与埋点禁止记录完整值;展示统一脱敏。 +- 接口启用 CSRF 或同站策略、速率限制、风控挑战、签名回调和重放保护。 +- 第三方客服 iframe 设置最小 sandbox 权限、allowlist 域名和清晰的数据传输告知。 +- 上传文件限制 MIME、扩展名、体积与像素,执行恶意内容扫描,使用隔离存储和短期访问 URL。 + +## 9.2 性能与可用性 + +- 首屏 LCP 目标小于 2.5 秒,核心 API P95 小于 800 毫秒,写操作 P95 小于 1.5 秒。 +- 按钮防抖、请求取消、重试与离线提示;长列表分页和图片懒加载。 +- 资金写入接口可用性目标 99.95%,客服与内容接口目标 99.9%。 +- 客户端时间仅用于展示,截止时间与状态以服务端为准。 + +## 9.3 可访问性与国际化 + +按钮和导航必须有语义标签与至少 44×44 像素触控区域;颜色不作为唯一状态标识;支持字体放大。简体、繁体和术语需统一,避免同一页面混用“储入/充值”“取出/提现”“领取/抢单”。 + +# 10 合规与业务风险 + +参考站点涉及先储入、任务扣款、返本付佣、推荐奖励、信用分和提现限制。此类模式在不同司法辖区可能触及支付、消费者保护、反洗钱、网络营销或其他监管要求。上线前需由法务和持牌支付机构评估,不应直接复制其资金规则、免责条款或客服收款模式。 + +| **风险** | **参考站点表现** | **新系统要求** | **优先级** | +| --- | --- | --- | --- | +| 资金真实性 | 客服提供收款方式,失败责任不清 | 使用合规支付通道、订单化收款与自动对账 | P0 | +| 诱导与不透明 | 高等级对应更高储入和收益 | 明确费用、风险、无保证收益,禁止误导 | P0 | +| 推荐奖励 | 三级佣金 16% 8% 4% | 法务审查后决定是否启用 | P0 | +| 账户冻结 | 规则含冻结、补差额、信用分 | 冻结需合法依据、通知、申诉和复核 | P0 | +| 内容安全 | 商品名出现脚本标签文本 | CMS 白名单与持久型 XSS 防护 | P0 | +| 密码安全 | 前端无盐 MD5 | 服务端强哈希、MFA、频控 | P0 | +| 隐私 | 收集银行账户和收款码 | 最小化、加密、授权、删除与访问审计 | P0 | +| 数据一致性 | 筛选短暂空列表、规则口径差异 | 单一配置源、请求竞态处理、自动化测试 | P1 | + +# 11 埋点与运营指标 + +| **事件** | **触发时机** | **关键属性** | +| --- | --- | --- | +| auth_login_result | 登录成功或失败 | result, errorCode, deviceRisk | +| home_shortcut_click | 点击首页快捷入口 | target, userLevel | +| order_claim_click | 点击领取 | eligibilityResult, balanceBucket, level | +| order_state_change | 订单状态变化 | orderNo, from, to, reason | +| deposit_submit | 提交储入申请 | amountBucket, channel, result | +| withdraw_submit | 提交取出申请 | amountBucket, fee, result | +| support_open | 打开客服会话 | provider, entry, workStatus | +| content_view | 查看规则或公告 | contentId, version, readDuration | +| security_challenge | 触发安全验证 | scene, result, ruleId | + +核心指标包括注册转化、首个任务完成率、领取成功率、订单超时率、储入成功率、取出成功率与处理时长、客服转人工率、异常账户率、账务差错率和投诉率。涉及金额与身份的数据只做区间化或匿名化统计。 + +# 12 验收标准 + +1. 未登录用户只能访问登录、注册和公开协议;受保护路由统一拦截。 + +2. 同一用户重复点击领取或提交订单,不产生重复业务单和重复资金流水。 + +3. 订单完成后,订单状态、钱包余额、三类流水和统计数据在一次事务或可验证最终一致性中完成。 + +4. 储入与取出所有状态均可在记录页查询,失败和驳回有明确原因。 + +5. 等级规则由后台发布后前台一致生效,表格、领取校验与提现限制不存在口径差异。 + +6. 手机号、银行卡、邀请码和交易密码不出现在客户端日志、服务端普通日志或分析平台。 + +7. 富文本、商品名、公告和客服消息中的脚本或危险 HTML 不能执行。 + +8. 接口 401、403、429、5xx、超时和断网场景均有清晰恢复路径。 + +9. 移动端 320 至 430 像素宽度可用,底部导航不遮挡内容,键盘弹出时表单按钮可达。 + +10. 关键资金和权限操作在后台可按用户、业务单号和时间完整审计。 + +# 13 交付优先级与里程碑 + +| **阶段** | **范围** | **退出条件** | +| --- | --- | --- | +| MVP 认证与账户 | 登录注册、资料、钱包、台账、内容、客服 | 权限、安全、审计基线通过 | +| MVP 任务闭环 | 任务列表、领取、详情、提交、状态机、佣金 | 幂等和账务一致性测试通过 | +| 资金闭环 | 储入、取出、收款绑定、支付通道、对账 | 支付与法务合规评审通过 | +| 运营能力 | 等级、公告、横幅、商品、通知、后台 | 配置版本和发布审核可用 | +| 增长与优化 | 邀请关系、数据看板、体验优化 | 合规批准且核心指标稳定 | + +# 14 低保真原型 + +以下原型根据实测页面重新绘制,用于确认页面层级、组件位置和交互入口,不复制原站品牌素材、头像或敏感数据。视觉设计阶段应重新建立色彩、字体、图标和组件规范。 + +![低保真原型图 1](移动端任务与账户平台需求文档.assets/figure-01.png) + +图 1 参考站点功能复刻原型 + +![低保真原型图 2](移动端任务与账户平台需求文档.assets/figure-02.png) + +图 2 参考站点功能复刻原型 + +![低保真原型图 3](移动端任务与账户平台需求文档.assets/figure-03.png) + +图 3 参考站点功能复刻原型 + +![低保真原型图 4](移动端任务与账户平台需求文档.assets/figure-04.png) + +图 4 参考站点功能复刻原型 + +# 15 待确认事项 + +- 目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。 +- 任务来源、商品真实性、结算主体和佣金承担方。 +- 目标市场与适用法律、支付通道和实名要求。 +- 是否保留会员等级、推荐奖励、信用分和冻结机制。 +- 客服使用自建系统还是第三方服务,允许传输哪些用户数据。 +- 管理后台的组织结构、审批层级和财务对账责任人。 +- 品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。 + +在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。 diff --git a/docs/迁移说明.md b/docs/迁移说明.md deleted file mode 100644 index 5910e78..0000000 --- a/docs/迁移说明.md +++ /dev/null @@ -1,56 +0,0 @@ -# 工作台服务迁移说明 - -本仓库承接 `im_server` 中的工作台接口,第一阶段目标是让工作台服务独立编译、独立启动、独立鉴权,并由网关把 `/v1/workbench/*` 分流到本服务。 - -## 已迁移接口 - -| 模块 | 新路径 | -|---|---| -| 工作台入口 | `GET /v1/workbench` | -| 组织架构 | `/v1/workbench/organization/*` | -| 考勤 | `/v1/workbench/attendance/*` | -| 会议 | `/v1/workbench/meetings*` | -| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | -| 日报 | `/v1/workbench/reports/daily*` | -| 月报 | `/v1/workbench/reports/monthly*` | -| 总经理信箱 | `/v1/workbench/executive-mailbox/*` | - -## 当前边界 - -1. 数据库暂不迁移,仍访问现有租户库工作台表。 -2. 文件上传仍由 `im_server` 的 `/v0/file/u/` 处理,工作台只保存 `file_id` 引用。 -3. C 端实时通知仍由 `im_server` 下发;工作台后续只投 Kafka 业务事件。 -4. `im_server` 原工作台代码暂不删除,待本服务联调稳定后再清理。 -5. 服务启动不自动创建或修改表结构。 - -## 待联调项 - -1. `im_server` 登录后写入 Redis session:`im:session:token:{sha256(token)}`。 -2. Nginx/API Gateway 路由:`/v1/workbench/* -> im_workbench:6061`。 -3. 工作台业务事件 Kafka 化:会议、考勤、日报、月报、信箱事件投递给 `im_server`。 -4. 数据库专项迁移:接口稳定后再从租户库拆到工作台库。 - -## Kafka 事件范围 - -当前工作台服务生产端统一投递到 `im.workbench.event`,由 `im_server` 后续接入消费组并转为系统通知、未读数或 WS 实时下发。 - -| 模块 | Action | 触发场景 | -|---|---|---| -| 考勤 | `attendance.record.updated` | 打卡成功 | -| 考勤 | `attendance.appeal.created` | 提交考勤申诉 | -| 会议 | `meeting.created` | 创建会议 | -| 会议 | `meeting.updated` | 修改会议 | -| 会议 | `meeting.canceled` | 取消会议 | -| 会议 | `meeting.response.updated` | 参会人更新回执 | -| 会议笔记 | `meeting_note.file.created` | 新增会议笔记文件 | -| 会议笔记 | `meeting_note.file.replaced` | 替换会议笔记文件 | -| 会议笔记 | `meeting_note.file.deleted` | 删除会议笔记文件 | -| 日报 | `daily_report.draft.created` | 创建日报草稿 | -| 日报 | `daily_report.draft.saved` | 保存日报草稿 | -| 日报 | `daily_report.submitted` | 提交日报 | -| 日报 | `daily_report.reminded` | 日报提醒 | -| 月报 | `monthly_report.draft.created` | 创建月报草稿 | -| 月报 | `monthly_report.draft.saved` | 保存月报草稿 | -| 月报 | `monthly_report.submitted` | 提交月报 | -| 月报 | `monthly_report.reopened` | 重开月报 | -| 信箱 | `mailbox.submitted` | 提交总经理信箱 | diff --git a/go.mod b/go.mod index 5fb923e..1a1b51c 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,10 @@ module git.118im.com/im_team/im_workbench go 1.26.0 require ( - github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible 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 - github.com/xuri/excelize/v2 v2.11.0 golang.org/x/crypto v0.53.0 ) @@ -18,25 +15,12 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/richardlehane/mscfb v1.0.7 // indirect - github.com/richardlehane/msoleps v1.0.6 // indirect - github.com/tiendc/go-deepcopy v1.7.2 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/time v0.16.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 98a31f9..eb7a30b 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ 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/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= -github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= 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= @@ -37,8 +35,6 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o 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= @@ -51,70 +47,18 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= -github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= -github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= -github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= -github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= -github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= -github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= -github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= -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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -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= diff --git a/internal/accounts/profile_test.go b/internal/accounts/profile_test.go new file mode 100644 index 0000000..b5bf656 --- /dev/null +++ b/internal/accounts/profile_test.go @@ -0,0 +1,123 @@ +package accounts + +import ( + "context" + "errors" + "testing" + + "git.118im.com/im_team/im_workbench/internal/store" + "golang.org/x/crypto/bcrypt" +) + +type profileRepository struct { + overview *store.MemberOverview + security *store.AccountSecurity + loginPasswordHash string + tradePasswordHash string +} + +func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { + return nil, errors.New("not implemented") +} +func (r *profileRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) { + return nil, errors.New("not implemented") +} +func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil } +func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) { + return r.overview, nil +} +func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) { + return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil +} +func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) { + return r.security, nil +} +func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error { + r.loginPasswordHash = newHash + return nil +} +func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error { + r.tradePasswordHash = newHash + return nil +} + +func TestProfileMapsMemberCategory(t *testing.T) { + repository := &profileRepository{overview: &store.MemberOverview{ + MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2, + Currency: "CNY", WalletStatus: 1, + }} + service := &Service{repository: repository, ready: true} + profile, err := service.Profile(context.Background(), 0, 7) + if err != nil { + t.Fatal(err) + } + if profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" { + t.Fatalf("unexpected profile states: %+v", profile) + } +} + +func TestChangePasswordVerifiesAndHashes(t *testing.T) { + currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}} + service := &Service{repository: repository, ready: true} + if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{ + OldPassword: "old-secret", NewPassword: "new-secret", + }); err != nil { + t.Fatal(err) + } + if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil { + t.Fatal("new login password was not bcrypt hashed") + } +} + +func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) { + currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost) + repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}} + service := &Service{repository: repository, ready: true} + err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{ + OldPassword: "wrong-secret", NewPassword: "new-secret", + }) + if !errors.Is(err, ErrCurrentPassword) { + t.Fatalf("error = %v, want %v", err, ErrCurrentPassword) + } +} + +func TestSetTradePasswordRequiresSixDigits(t *testing.T) { + loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost) + repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}} + service := &Service{repository: repository, ready: true} + for _, password := range []string{"", "12345", "1234567", "abcdef"} { + err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{ + LoginPassword: "login-secret", NewTradePassword: password, + }) + if !errors.Is(err, ErrTradePassword) { + t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword) + } + } + if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{ + LoginPassword: "login-secret", NewTradePassword: "654321", + }); err != nil { + t.Fatal(err) + } + if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil { + t.Fatal("trade password was not bcrypt hashed") + } +} + +func TestChangeTradePasswordRequiresOldPassword(t *testing.T) { + loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost) + tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost) + repository := &profileRepository{security: &store.AccountSecurity{ + PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash), + }} + service := &Service{repository: repository, ready: true} + err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{ + LoginPassword: "login-secret", NewTradePassword: "654321", + }) + if !errors.Is(err, ErrOldTradePasswordUnset) { + t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset) + } +} diff --git a/internal/accounts/service.go b/internal/accounts/service.go new file mode 100644 index 0000000..d48fe32 --- /dev/null +++ b/internal/accounts/service.go @@ -0,0 +1,460 @@ +package accounts + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" + "git.118im.com/im_team/im_workbench/internal/store" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrUnavailable = errors.New("account service unavailable") + ErrUsernameInvalid = errors.New("username invalid") + ErrPhoneInvalid = errors.New("phone invalid") + ErrPasswordInvalid = errors.New("password invalid") + ErrReferralInvalid = errors.New("referral code invalid") + ErrPrivacyNotAccepted = errors.New("privacy policy not accepted") + ErrRateLimited = errors.New("login rate limited") + ErrCurrentPassword = errors.New("current password invalid") + ErrLoginPassword = errors.New("login password invalid") + ErrTradePassword = errors.New("trade password invalid") + ErrOldTradePassword = errors.New("old trade password invalid") + ErrOldTradePasswordUnset = errors.New("old trade password is required") + ErrAttachmentInvalid = errors.New("attachment invalid") +) + +var ( + usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`) + phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`) + referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`) + tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) +) + +type Repository interface { + RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) + AccountByMobileHash(context.Context, int64, string) (*store.Account, error) + RecordLogin(context.Context, int64, int64) error + MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) + UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) + AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) + UpdateLoginPassword(context.Context, int64, int64, string, string) error + UpdateTradePassword(context.Context, int64, int64, string, string) error +} + +type RegisterInput struct { + Username string + Phone string + Password string + ReferralCode string + PrivacyAccepted bool + DeviceID string +} + +type LoginInput struct { + Phone string + Password string + DeviceID string +} + +type Result struct { + Account *store.Account + Token string + ExpiresAt time.Time +} + +type Profile struct { + MemberID int64 `json:"member_id"` + MemberNo string `json:"member_no"` + Username string `json:"username"` + PhoneMask string `json:"phone_mask"` + AvatarURL string `json:"avatar_url"` + InviteCode string `json:"invite_code"` + Level ProfileLevel `json:"level"` + MemberStatus string `json:"member_status"` + MemberCategory string `json:"member_category"` + CreditScore int `json:"credit_score"` + Wallet ProfileWallet `json:"wallet"` +} + +type ProfileLevel struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IconURL string `json:"icon_url"` + CommissionRate int `json:"commission_rate"` +} + +type ProfileWallet struct { + AvailableAmount int64 `json:"available_amount"` + FrozenAmount int64 `json:"frozen_amount"` + TotalIncome int64 `json:"total_income"` + TotalExpense int64 `json:"total_expense"` + Currency string `json:"currency"` + Status string `json:"status"` + Version int `json:"version"` +} + +type AvatarResult struct { + AttachmentID int64 `json:"attachment_id"` + URL string `json:"avatar_url"` +} + +type ChangePasswordInput struct { + OldPassword string + NewPassword string +} + +type ChangeTradePasswordInput struct { + LoginPassword string + OldTradePassword string + NewTradePassword string +} + +type Service struct { + repository Repository + redis *redis.Client + cfg config.AuthConfig + aead cipher.AEAD + lookupKey []byte + ready bool +} + +func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { + service := &Service{repository: repository, redis: redisClient, cfg: cfg} + encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) + lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey) + if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { + return service + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return service + } + aead, err := cipher.NewGCM(block) + if err != nil { + return service + } + service.aead = aead + service.lookupKey = lookupKey + service.ready = repository != nil && redisClient != nil + return service +} + +func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) { + if !s.ready { + return nil, ErrUnavailable + } + input.Username = strings.ToLower(strings.TrimSpace(input.Username)) + input.Phone = strings.TrimSpace(input.Phone) + input.ReferralCode = strings.TrimSpace(input.ReferralCode) + if !usernamePattern.MatchString(input.Username) { + return nil, ErrUsernameInvalid + } + if !phonePattern.MatchString(input.Phone) { + return nil, ErrPhoneInvalid + } + if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 { + return nil, ErrPasswordInvalid + } + if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) { + return nil, ErrReferralInvalid + } + if !input.PrivacyAccepted { + return nil, ErrPrivacyNotAccepted + } + + normalizedPhone := "+86" + input.Phone + mobileCipher, err := s.encrypt(normalizedPhone) + if err != nil { + return nil, err + } + mobileHash := s.lookupHash(normalizedPhone) + passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + memberNo, err := newPublicID("M", 21) + if err != nil { + return nil, err + } + inviteCode, err := newPublicID("", 8) + if err != nil { + return nil, err + } + account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{ + TenantID: s.cfg.DefaultTenantID, + MemberNo: memberNo, + Username: input.Username, + MobileCipher: mobileCipher, + MobileHash: mobileHash, + MobileMask: maskPhone(input.Phone), + PasswordHash: string(passwordHash), + ReferralCode: input.ReferralCode, + InviteCode: inviteCode, + }) + if err != nil { + if errors.Is(err, store.ErrReferralInvalid) { + return nil, ErrReferralInvalid + } + return nil, err + } + return s.issueSession(ctx, account, input.DeviceID) +} + +func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) { + if !s.ready { + return nil, ErrUnavailable + } + phone := strings.TrimSpace(input.Phone) + if !phonePattern.MatchString(phone) { + return nil, ErrPhoneInvalid + } + if input.Password == "" || len([]byte(input.Password)) > 72 { + return nil, store.ErrInvalidCredentials + } + mobileHash := s.lookupHash("+86" + phone) + rateKey := s.loginRateKey(mobileHash) + if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 { + return nil, ErrRateLimited + } + account, err := s.repository.AccountByMobileHash(ctx, s.cfg.DefaultTenantID, mobileHash) + if err != nil { + if errors.Is(err, store.ErrInvalidCredentials) { + s.recordLoginFailure(ctx, rateKey) + } + return nil, err + } + if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil { + s.recordLoginFailure(ctx, rateKey) + return nil, store.ErrInvalidCredentials + } + if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil { + return nil, err + } + result, err := s.issueSession(ctx, account, input.DeviceID) + if err == nil { + _ = s.redis.Del(ctx, rateKey).Err() + } + return result, err +} + +func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) { + if !s.ready { + return nil, ErrUnavailable + } + row, err := s.repository.MemberOverview(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + return &Profile{ + MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username, + PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode, + Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName, + IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate}, + MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory), + CreditScore: row.CreditScore, + Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount, + TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency, + Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion}, + }, nil +} + +func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) { + if !s.ready { + return nil, ErrUnavailable + } + if attachmentID <= 0 { + return nil, ErrAttachmentInvalid + } + avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID) + if errors.Is(err, store.ErrAttachmentInvalid) { + return nil, ErrAttachmentInvalid + } + if err != nil { + return nil, err + } + return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil +} + +func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error { + if !s.ready { + return ErrUnavailable + } + if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 { + return ErrCurrentPassword + } + if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 { + return ErrPasswordInvalid + } + security, err := s.repository.AccountSecurity(ctx, tenantID, memberID) + if err != nil { + return err + } + if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil { + return ErrCurrentPassword + } + if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil { + return ErrPasswordInvalid + } + newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash)) +} + +func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error { + if !s.ready { + return ErrUnavailable + } + if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 { + return ErrLoginPassword + } + if !tradePasswordPattern.MatchString(input.NewTradePassword) { + return ErrTradePassword + } + security, err := s.repository.AccountSecurity(ctx, tenantID, memberID) + if err != nil { + return err + } + if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil { + return ErrLoginPassword + } + if security.TradePasswordHash != "" { + if input.OldTradePassword == "" { + return ErrOldTradePasswordUnset + } + if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil { + return ErrOldTradePassword + } + if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil { + return ErrTradePassword + } + } else if input.OldTradePassword != "" { + return ErrOldTradePassword + } + newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost) + if err != nil { + return err + } + return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash)) +} + +func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + token := base64.RawURLEncoding.EncodeToString(raw) + expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond) + payload, err := json.Marshal(map[string]any{ + "member_id": account.MemberID, + "tenant_id": account.TenantID, + "device_id": strings.TrimSpace(deviceID), + "state": "active", + "issued_at": time.Now().UTC().Format(time.RFC3339Nano), + "expires_at": expiresAt.Format(time.RFC3339), + }) + if err != nil { + return nil, err + } + if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil { + return nil, err + } + return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil +} + +func (s *Service) encrypt(value string) (string, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + sealed := s.aead.Seal(nil, nonce, []byte(value), nil) + return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil +} + +func (s *Service) lookupHash(value string) string { + mac := hmac.New(sha256.New, s.lookupKey) + _, _ = mac.Write([]byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Service) loginRateKey(mobileHash string) string { + return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + mobileHash +} + +func (s *Service) recordLoginFailure(ctx context.Context, key string) { + pipe := s.redis.TxPipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, 15*time.Minute) + _, _ = pipe.Exec(ctx) +} + +func decodeKey(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.RawStdEncoding.DecodeString(value) +} + +func sessionKey(pattern string, rawToken []byte) string { + digest := sha256.Sum256(rawToken) + return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1) +} + +func maskPhone(phone string) string { + if len(phone) != 11 { + return "" + } + return phone[:3] + "****" + phone[7:] +} + +func newPublicID(prefix string, length int) (string, error) { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + raw := make([]byte, length-len(prefix)) + if _, err := rand.Read(raw); err != nil { + return "", err + } + result := make([]byte, len(raw)) + for i, value := range raw { + result[i] = alphabet[int(value)%len(alphabet)] + } + return prefix + string(result), nil +} + +func memberStatusName(status int) string { + if status == 1 { + return "active" + } + return "disabled" +} + +func memberCategoryName(category int) string { + if category == 2 { + return "fake" + } + return "real" +} + +func walletStatusName(status int) string { + if status == 1 { + return "active" + } + return "disabled" +} diff --git a/internal/accounts/service_test.go b/internal/accounts/service_test.go new file mode 100644 index 0000000..be8f836 --- /dev/null +++ b/internal/accounts/service_test.go @@ -0,0 +1,100 @@ +package accounts + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "errors" + "testing" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +type fakeRepository struct{} + +func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { + return nil, errors.New("should not be called") +} +func (fakeRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) { + return nil, errors.New("should not be called") +} +func (fakeRepository) RecordLogin(context.Context, int64, int64) error { + return errors.New("should not be called") +} +func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) { + return nil, errors.New("should not be called") +} +func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) { + return nil, errors.New("should not be called") +} +func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) { + return nil, errors.New("should not be called") +} +func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error { + return errors.New("should not be called") +} +func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error { + return errors.New("should not be called") +} + +func TestRegisterValidation(t *testing.T) { + s := &Service{repository: fakeRepository{}, ready: true} + tests := []struct { + name string + input RegisterInput + want error + }{ + {name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid}, + {name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid}, + {name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid}, + {name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid}, + {name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := s.Register(context.Background(), test.input) + if !errors.Is(err, test.want) { + t.Fatalf("Register() error = %v, want %v", err, test.want) + } + }) + } +} + +func TestMobileEncryptionRoundTrip(t *testing.T) { + key := make([]byte, 32) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + s := &Service{aead: aead, lookupKey: []byte("01234567890123456789012345678901")} + encrypted, err := s.encrypt("+8613800138000") + if err != nil { + t.Fatal(err) + } + raw, err := base64.RawStdEncoding.DecodeString(encrypted) + if err != nil { + t.Fatal(err) + } + plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil) + if err != nil || string(plain) != "+8613800138000" { + t.Fatalf("decrypt = %q, err=%v", plain, err) + } + if len(s.lookupHash("+8613800138000")) != 64 { + t.Fatal("mobile lookup hash must be hex sha256") + } +} + +func TestLoginRequiresMainlandPhone(t *testing.T) { + s := &Service{repository: fakeRepository{}, ready: true} + for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} { + _, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"}) + if !errors.Is(err, ErrPhoneInvalid) { + t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid) + } + } +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go deleted file mode 100644 index 8f14b8f..0000000 --- a/internal/ai/provider.go +++ /dev/null @@ -1,197 +0,0 @@ -package ai - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/config" -) - -var ( - ErrNotConfigured = errors.New("ai provider is not configured") - ErrProvider = errors.New("ai provider failed") - ErrTimeout = errors.New("ai provider timeout") - ErrBadOutput = errors.New("ai provider returned invalid output") -) - -type Client struct { - cfg config.AIConfig - httpClient *http.Client -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type GenerateRequest struct { - Feature string - Model string - Messages []Message - Temperature float64 - MaxTokens int -} - -type GenerateResponse struct { - ProviderRequestID string - Model string - Content string - PromptTokens int - CompletionTokens int - TotalTokens int - FinishReason string - LatencyMS int64 -} - -func NewClient(cfg config.AIConfig) *Client { - timeout := time.Duration(cfg.DeepSeek.TimeoutMS) * time.Millisecond - if timeout <= 0 { - timeout = 60 * time.Second - } - return &Client{cfg: cfg, httpClient: &http.Client{Timeout: timeout}} -} - -func (c *Client) Configured() bool { - return c != nil && strings.TrimSpace(c.cfg.DeepSeek.APIKey) != "" -} - -func (c *Client) DefaultModel() string { - if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" { - return "deepseek-flash" - } - return strings.TrimSpace(c.cfg.DefaultModel) -} - -func (c *Client) ComplexModel() string { - if c == nil || strings.TrimSpace(c.cfg.ComplexModel) == "" { - return c.DefaultModel() - } - return strings.TrimSpace(c.cfg.ComplexModel) -} - -func (c *Client) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { - if c == nil || strings.TrimSpace(c.cfg.DeepSeek.APIKey) == "" { - return GenerateResponse{}, ErrNotConfigured - } - model := strings.TrimSpace(req.Model) - if model == "" { - model = c.DefaultModel() - } - temperature := req.Temperature - if temperature == 0 { - temperature = c.cfg.Temperature - } - if temperature == 0 { - temperature = 0.2 - } - maxTokens := req.MaxTokens - if maxTokens <= 0 { - maxTokens = c.cfg.MaxOutputTokens - } - if maxTokens <= 0 { - maxTokens = 4096 - } - body := map[string]any{ - "model": model, - "messages": req.Messages, - "temperature": temperature, - "max_tokens": maxTokens, - "stream": false, - } - payload, err := json.Marshal(body) - if err != nil { - return GenerateResponse{}, err - } - baseURL := strings.TrimRight(firstNonEmpty(c.cfg.DeepSeek.BaseURL, "https://api.deepseek.com"), "/") - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(payload)) - if err != nil { - return GenerateResponse{}, err - } - httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.DeepSeek.APIKey)) - httpReq.Header.Set("Content-Type", "application/json") - start := time.Now() - resp, err := c.httpClient.Do(httpReq) - if err != nil { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return GenerateResponse{}, ErrTimeout - } - return GenerateResponse{}, fmt.Errorf("%w: %v", ErrProvider, err) - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout { - return GenerateResponse{}, ErrTimeout - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return GenerateResponse{}, fmt.Errorf("%w: deepseek status %d: %s", ErrProvider, resp.StatusCode, string(raw)) - } - var parsed chatCompletionResponse - if err := json.Unmarshal(raw, &parsed); err != nil { - return GenerateResponse{}, fmt.Errorf("%w: %v", ErrBadOutput, err) - } - if len(parsed.Choices) == 0 { - return GenerateResponse{}, ErrBadOutput - } - content := parsed.Choices[0].Message.Content - if strings.TrimSpace(content) == "" { - return GenerateResponse{}, ErrBadOutput - } - return GenerateResponse{ - ProviderRequestID: parsed.ID, - Model: firstNonEmpty(parsed.Model, model), - Content: content, - PromptTokens: parsed.Usage.PromptTokens, - CompletionTokens: parsed.Usage.CompletionTokens, - TotalTokens: parsed.Usage.TotalTokens, - FinishReason: parsed.Choices[0].FinishReason, - LatencyMS: time.Since(start).Milliseconds(), - }, nil -} - -func ExtractJSONObject(raw string) string { - text := strings.TrimSpace(raw) - if strings.HasPrefix(text, "```") { - text = strings.TrimPrefix(text, "```json") - text = strings.TrimPrefix(text, "```") - text = strings.TrimSuffix(text, "```") - text = strings.TrimSpace(text) - } - start := strings.Index(text, "{") - end := strings.LastIndex(text, "}") - if start >= 0 && end >= start { - return text[start : end+1] - } - return text -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -type chatCompletionResponse struct { - ID string `json:"id"` - Model string `json:"model"` - Choices []struct { - FinishReason string `json:"finish_reason"` - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` -} diff --git a/internal/ai/service.go b/internal/ai/service.go deleted file mode 100644 index 2a7a16c..0000000 --- a/internal/ai/service.go +++ /dev/null @@ -1,560 +0,0 @@ -package ai - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -type Service struct { - client *Client -} - -type Usage struct { - ProviderRequestID string - Model string - PromptTokens int - CompletionTokens int - TotalTokens int - LatencyMS int64 -} - -type PolishResult struct { - Text string - Usage Usage -} - -type DocumentResult struct { - Title string - ContentMarkdown string - ContentPlain string - Sections []DocumentSection - Summary string - Usage Usage -} - -type DocumentSection struct { - Heading string `json:"heading"` - Content string `json:"content"` -} - -type TableAnalysisInput struct { - Topic string `json:"topic"` - FileID string `json:"file_id"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - FileSize int64 `json:"file_size"` - Profile TableProfile `json:"profile"` -} - -type TableAnalysisResult struct { - Title string - Metrics []TableMetric - Preview TablePreview - Alerts []TableAlert - Interpretation string - Summary string - Usage Usage -} - -type TableMetric struct { - Name string `json:"name"` - Value string `json:"value"` - ChangeText string `json:"change_text"` - Trend string `json:"trend"` -} - -type TablePreview struct { - Columns []string `json:"columns"` - Rows [][]string `json:"rows"` - TotalRows int `json:"total_rows"` -} - -type TableAlert struct { - Level string `json:"level"` - Title string `json:"title"` - Description string `json:"description"` -} - -type TableProfile struct { - Columns any `json:"columns,omitempty"` - SampleRows any `json:"sample_rows,omitempty"` - RowCount int `json:"row_count,omitempty"` - Stats any `json:"stats,omitempty"` - Outliers any `json:"outliers,omitempty"` - Parsed bool `json:"parsed"` - Message string `json:"message,omitempty"` -} - -type MeetingSummaryInput struct { - MeetingTitle string - StartAt string - EndAt string - Participants []string - Files []string -} - -type MeetingSummaryResult struct { - Conclusions []string - Decisions []string - ActionItems []MeetingActionItem - Usage Usage -} - -type MeetingActionItem struct { - OwnerName string `json:"owner_name"` - Content string `json:"content"` - DueText string `json:"due_text"` - Status string `json:"status"` -} - -func NewService(client *Client) *Service { - return &Service{client: client} -} - -func (s *Service) Configured() bool { - return s != nil && s.client != nil && s.client.Configured() -} - -func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) { - if s == nil || s.client == nil { - return PolishResult{}, ErrNotConfigured - } - if maxOutputChars <= 0 { - maxOutputChars = 300 - } - system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。" - user := fmt.Sprintf(`请润色下面的中文工作文案。 - -要求: -1. 语气:%s。 -2. 场景:%s。 -3. 输出不超过 %d 个中文字符。 -4. 保持事实不变,不编造数据、人名、时间和结论。 -5. 只输出 JSON:{"polished_text":"..."}。 - -原文: -%s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText) - resp, err := s.client.Generate(ctx, GenerateRequest{ - Feature: "text_polish", - Model: s.client.DefaultModel(), - Messages: []Message{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - }, - MaxTokens: maxOutputChars * 3, - }) - if err != nil { - return PolishResult{}, err - } - var out struct { - PolishedText string `json:"polished_text"` - } - if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) - } - out.PolishedText = strings.TrimSpace(out.PolishedText) - if out.PolishedText == "" { - return PolishResult{}, ErrBadOutput - } - return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil -} - -func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) { - if s == nil || s.client == nil { - return DocumentResult{}, ErrNotConfigured - } - system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" - user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。 - -要求: -1. 输出语言:%s。 -2. 文档类型:%s。 -3. 标题提示:%s。 -4. 内容要忠于原文,不编造事实。 -5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。 -6. content_markdown 要适合直接展示,使用二级标题和列表。 -7. 只输出 JSON,格式: -{ - "title": "string", - "summary": "string", - "content_markdown": "string", - "content_plain": "string", - "sections": [{"heading":"string","content":"string"}] -} - -用户输入: -%s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText) - resp, err := s.client.Generate(ctx, GenerateRequest{ - Feature: "smart_document", - Model: s.client.DefaultModel(), - Messages: []Message{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - }, - MaxTokens: 2400, - }) - if err != nil { - return DocumentResult{}, err - } - usage := usageFromResponse(resp) - var out struct { - Title string `json:"title"` - Summary string `json:"summary"` - ContentMarkdown string `json:"content_markdown"` - ContentPlain string `json:"content_plain"` - Sections []DocumentSection `json:"sections"` - } - if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出格式异常,已使用兜底文档。"), nil - } - out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档")) - out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown) - if out.ContentMarkdown == "" && len(out.Sections) > 0 { - out.ContentMarkdown = markdownFromSections(out.Sections) - } - if strings.TrimSpace(out.ContentPlain) == "" { - out.ContentPlain = plainFromSections(out.Sections) - } - if out.ContentMarkdown == "" { - return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出缺少文档正文,已使用兜底文档。"), nil - } - return DocumentResult{ - Title: out.Title, - ContentMarkdown: out.ContentMarkdown, - ContentPlain: strings.TrimSpace(out.ContentPlain), - Sections: out.Sections, - Summary: strings.TrimSpace(out.Summary), - Usage: usage, - }, nil -} - -func fallbackDocument(sourceText, titleHint, documentType string, usage Usage, reason string) DocumentResult { - title := firstNonEmpty(titleHint, documentTypeTitle(documentType), excerptPlain(sourceText, 30), "智能文档") - source := strings.TrimSpace(sourceText) - if source == "" { - source = "用户未提供有效原文。" - } - sections := []DocumentSection{ - {Heading: "处理说明", Content: reason}, - {Heading: "用户输入", Content: source}, - {Heading: "后续建议", Content: "建议检查原始输入是否完整,或重新发起生成以获取更完整的智能文档。"}, - } - contentMarkdown := markdownFromSections(sections) - return DocumentResult{ - Title: title, - ContentMarkdown: contentMarkdown, - ContentPlain: plainFromSections(sections), - Sections: sections, - Summary: excerptPlain(source, 120), - Usage: usage, - } -} - -func documentTypeTitle(documentType string) string { - switch strings.TrimSpace(documentType) { - case "meeting_minutes": - return "会议纪要" - case "speech": - return "演讲稿" - case "proposal": - return "方案文档" - case "summary": - return "内容总结" - default: - return "" - } -} - -func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) { - if s == nil || s.client == nil { - return TableAnalysisResult{}, ErrNotConfigured - } - system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" - profileJSON, _ := json.Marshal(input.Profile) - user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。 - -注意: -1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。 -2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。 - -报表主题:%s -文件名:%s -文件类型:%s -文件大小:%d bytes -表格画像:%s - -只输出 JSON: -{ - "title": "string", - "summary": "string", - "metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}], - "preview": {"columns":["string"],"rows":[["string"]],"total_rows":0}, - "alerts": [{"level":"info|warning|danger","title":"string","description":"string"}], - "interpretation": "string" -}`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON)) - resp, err := s.client.Generate(ctx, GenerateRequest{ - Feature: "smart_table", - Model: s.client.ComplexModel(), - Messages: []Message{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - }, - MaxTokens: 1800, - }) - if err != nil { - return TableAnalysisResult{}, err - } - usage := usageFromResponse(resp) - var out struct { - Title string `json:"title"` - Summary string `json:"summary"` - Metrics []TableMetric `json:"metrics"` - Preview TablePreview `json:"preview"` - Alerts []TableAlert `json:"alerts"` - Interpretation string `json:"interpretation"` - } - if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return fallbackTableAnalysis(input, usage, "AI 输出格式异常,已使用兜底解读。"), nil - } - out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告") - if strings.TrimSpace(out.Interpretation) == "" { - return fallbackTableAnalysis(input, usage, "AI 输出缺少核心解读,已使用兜底解读。"), nil - } - return TableAnalysisResult{ - Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts, - Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usage, - }, nil -} - -func fallbackTableAnalysis(input TableAnalysisInput, usage Usage, reason string) TableAnalysisResult { - title := firstNonEmpty(input.Topic, input.FileName, "智能表格解读报告") - preview := fallbackTablePreview(input.Profile) - metrics := fallbackTableMetrics(input, preview) - alerts := []TableAlert{{ - Level: "warning", - Title: "已启用兜底解读", - Description: reason, - }} - if !input.Profile.Parsed { - alerts = append(alerts, TableAlert{ - Level: "warning", - Title: "表格内容未解析", - Description: firstNonEmpty(input.Profile.Message, "当前无法读取表格内容,仅基于文件元信息生成解读。"), - }) - } - interpretation := fallbackTableInterpretation(input, preview, reason) - return TableAnalysisResult{ - Title: title, - Summary: excerptPlain(interpretation, 120), - Metrics: metrics, - Preview: preview, - Alerts: alerts, - Interpretation: interpretation, - Usage: usage, - } -} - -func fallbackTablePreview(profile TableProfile) TablePreview { - var columns []struct { - Name string `json:"name"` - Type string `json:"type"` - } - _ = decodeProfileAny(profile.Columns, &columns) - names := make([]string, 0, len(columns)) - for _, column := range columns { - if name := strings.TrimSpace(column.Name); name != "" { - names = append(names, name) - } - } - var rows [][]string - _ = decodeProfileAny(profile.SampleRows, &rows) - if len(rows) > 5 { - rows = rows[:5] - } - return TablePreview{Columns: names, Rows: rows, TotalRows: profile.RowCount} -} - -func fallbackTableMetrics(input TableAnalysisInput, preview TablePreview) []TableMetric { - metrics := []TableMetric{ - {Name: "文件类型", Value: strings.ToUpper(input.FileType), ChangeText: "来自上传元信息", Trend: "flat"}, - {Name: "文件大小", Value: fmt.Sprintf("%d bytes", input.FileSize), ChangeText: "来自上传元信息", Trend: "flat"}, - } - if input.Profile.Parsed { - metrics = append(metrics, - TableMetric{Name: "数据行数", Value: fmt.Sprintf("%d", preview.TotalRows), ChangeText: "来自表格解析", Trend: "flat"}, - TableMetric{Name: "字段数", Value: fmt.Sprintf("%d", len(preview.Columns)), ChangeText: "来自表格解析", Trend: "flat"}, - ) - } else { - metrics = append(metrics, TableMetric{Name: "解析状态", Value: "未解析", ChangeText: firstNonEmpty(input.Profile.Message, "source file is not available locally"), Trend: "flat"}) - } - var stats map[string]struct { - Count int `json:"count"` - Sum float64 `json:"sum"` - Avg float64 `json:"avg"` - Min float64 `json:"min"` - Max float64 `json:"max"` - } - if err := decodeProfileAny(input.Profile.Stats, &stats); err == nil { - for name, stat := range stats { - if len(metrics) >= 6 { - break - } - metrics = append(metrics, TableMetric{ - Name: name, - Value: fmt.Sprintf("平均 %.2f", stat.Avg), - ChangeText: fmt.Sprintf("样本 %d,范围 %.2f-%.2f", stat.Count, stat.Min, stat.Max), - Trend: "flat", - }) - } - } - return metrics -} - -func fallbackTableInterpretation(input TableAnalysisInput, preview TablePreview, reason string) string { - var b strings.Builder - b.WriteString(reason) - b.WriteString("\n\n") - b.WriteString("文件《") - b.WriteString(firstNonEmpty(input.FileName, input.FileID, "未命名文件")) - b.WriteString("》已进入智能表格解读流程。") - if input.Profile.Parsed { - b.WriteString(fmt.Sprintf("当前已解析到 %d 行数据、%d 个字段。", preview.TotalRows, len(preview.Columns))) - if len(preview.Columns) > 0 { - b.WriteString("字段包括:") - b.WriteString(strings.Join(preview.Columns, "、")) - b.WriteString("。") - } - b.WriteString("建议结合字段含义继续查看数值分布、异常值和业务口径。") - } else { - b.WriteString("当前未能读取表格内容,报告仅基于文件名、类型和大小生成。") - if msg := strings.TrimSpace(input.Profile.Message); msg != "" { - b.WriteString("未解析原因:") - b.WriteString(msg) - b.WriteString("。") - } - b.WriteString("建议确认文件已上传完成、格式正确后重新发起解读。") - } - return b.String() -} - -func decodeProfileAny(value any, target any) error { - if value == nil { - return nil - } - data, err := json.Marshal(value) - if err != nil { - return err - } - return json.Unmarshal(data, target) -} - -func excerptPlain(value string, maxRunes int) string { - value = strings.TrimSpace(value) - if maxRunes <= 0 { - return "" - } - runes := []rune(value) - if len(runes) <= maxRunes { - return value - } - return string(runes[:maxRunes]) -} - -func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) { - if s == nil || s.client == nil { - return MeetingSummaryResult{}, ErrNotConfigured - } - system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" - payload, _ := json.Marshal(input) - user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。 - -要求: -1. 不编造会议中没有出现的事实。 -2. 若附件内容不足,请基于标题和上下文给出谨慎总结。 -3. action_items 的 status 固定为 pending。 -4. 只输出 JSON: -{ - "conclusions": ["string"], - "decisions": ["string"], - "action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}] -} - -会议上下文: -%s`, string(payload)) - resp, err := s.client.Generate(ctx, GenerateRequest{ - Feature: "meeting_summary", - Model: s.client.ComplexModel(), - Messages: []Message{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - }, - MaxTokens: 1800, - }) - if err != nil { - return MeetingSummaryResult{}, err - } - var out struct { - Conclusions []string `json:"conclusions"` - Decisions []string `json:"decisions"` - ActionItems []MeetingActionItem `json:"action_items"` - } - if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) - } - if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 { - return MeetingSummaryResult{}, ErrBadOutput - } - for i := range out.ActionItems { - if strings.TrimSpace(out.ActionItems[i].Status) == "" { - out.ActionItems[i].Status = "pending" - } - } - return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil -} - -func usageFromResponse(resp GenerateResponse) Usage { - return Usage{ - ProviderRequestID: resp.ProviderRequestID, - Model: resp.Model, - PromptTokens: resp.PromptTokens, - CompletionTokens: resp.CompletionTokens, - TotalTokens: resp.TotalTokens, - LatencyMS: resp.LatencyMS, - } -} - -func markdownFromSections(sections []DocumentSection) string { - var b strings.Builder - for _, section := range sections { - heading := strings.TrimSpace(section.Heading) - content := strings.TrimSpace(section.Content) - if heading == "" && content == "" { - continue - } - if heading != "" { - b.WriteString("## ") - b.WriteString(heading) - b.WriteString("\n") - } - if content != "" { - b.WriteString(content) - b.WriteString("\n\n") - } - } - return strings.TrimSpace(b.String()) -} - -func plainFromSections(sections []DocumentSection) string { - parts := make([]string, 0, len(sections)*2) - for _, section := range sections { - if heading := strings.TrimSpace(section.Heading); heading != "" { - parts = append(parts, heading) - } - if content := strings.TrimSpace(section.Content); content != "" { - parts = append(parts, content) - } - } - return strings.Join(parts, "\n") -} diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go deleted file mode 100644 index 839c21a..0000000 --- a/internal/ai/service_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package ai - -import ( - "strings" - "testing" -) - -func TestFallbackTableAnalysisUnparsedProfile(t *testing.T) { - result := fallbackTableAnalysis(TableAnalysisInput{ - Topic: "销售分析", - FileID: "file1", - FileName: "sales.xlsx", - FileType: "xlsx", - FileSize: 5149, - Profile: TableProfile{Parsed: false, Message: "source file is not available locally"}, - }, Usage{Model: "deepseek-flash"}, "AI 输出格式异常,已使用兜底解读。") - if result.Title != "销售分析" { - t.Fatalf("Title=%q, want 销售分析", result.Title) - } - if strings.TrimSpace(result.Interpretation) == "" { - t.Fatal("Interpretation is empty") - } - if len(result.Alerts) < 2 { - t.Fatalf("Alerts len=%d, want at least 2", len(result.Alerts)) - } - if got := result.Metrics[len(result.Metrics)-1].Value; got != "未解析" { - t.Fatalf("last metric value=%q, want 未解析", got) - } -} - -func TestFallbackDocumentBuildsDisplayableContent(t *testing.T) { - result := fallbackDocument( - "生成一个竞争销售部门副科长职位的演讲稿", - "竞争销售部门副科长职位演讲稿", - "meeting_minutes", - Usage{Model: "deepseek-flash"}, - "AI 输出格式异常,已使用兜底文档。", - ) - if result.Title != "竞争销售部门副科长职位演讲稿" { - t.Fatalf("Title=%q, want title hint", result.Title) - } - if strings.TrimSpace(result.ContentMarkdown) == "" { - t.Fatal("ContentMarkdown is empty") - } - if !strings.Contains(result.ContentMarkdown, "AI 输出格式异常") { - t.Fatalf("ContentMarkdown=%q, want fallback reason", result.ContentMarkdown) - } - if !strings.Contains(result.ContentPlain, "生成一个竞争销售部门副科长职位的演讲稿") { - t.Fatalf("ContentPlain=%q, want source text", result.ContentPlain) - } - if len(result.Sections) != 3 { - t.Fatalf("Sections len=%d, want 3", len(result.Sections)) - } -} diff --git a/internal/app/app.go b/internal/app/app.go index 89850f2..301c77f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,72 +5,47 @@ import ( "net/http" "time" - "git.118im.com/im_team/im_workbench/internal/ai" + "git.118im.com/im_team/im_workbench/internal/accounts" "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/finance" "git.118im.com/im_team/im_workbench/internal/httpapi" - "git.118im.com/im_team/im_workbench/internal/managerconfig" + "git.118im.com/im_team/im_workbench/internal/mall" + "git.118im.com/im_team/im_workbench/internal/portal" "git.118im.com/im_team/im_workbench/internal/store" + "git.118im.com/im_team/im_workbench/internal/tasks" "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 - cancel context.CancelFunc + store *store.Store + redis *redis.Client + api *httpapi.API } func New(cfg *config.Config) (*App, error) { - uidKey := managerconfig.ResolveUIDKey(context.Background(), cfg.Manager, cfg.Auth.UIDKey) - if err := httpapi.SetUIDKey(uidKey); err != nil { - return nil, err - } - if err := store.SetUIDKey(uidKey); err != nil { - return nil, err - } - cfg.AI = managerconfig.ResolveAIConfig(context.Background(), cfg.Manager, cfg.AI) - st, err := store.Open(cfg.MySQL, cfg.CoreMySQL) + 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() - } + 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 { + _ = redisClient.Close() _ = st.Close() return nil, err } - aiService := ai.NewService(ai.NewClient(cfg.AI)) - api := httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth, cfg.Manager, aiService) - ctx, cancel := context.WithCancel(context.Background()) - api.StartAIWorkers(ctx) - return &App{ - store: st, - redis: redisClient, - events: publisher, - api: api, - cancel: cancel, - }, nil + authenticator := auth.New(cfg.Auth, redisClient) + accountService := accounts.New(st, redisClient, cfg.Auth) + portalService := portal.New(st) + taskService := tasks.New(st) + mallService := mall.New(st) + financeService := finance.New(st, redisClient, cfg.Auth) + return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService)}, nil } func (a *App) Handler() http.Handler { @@ -82,15 +57,9 @@ func (a *App) Handler() http.Handler { } func (a *App) Close() { - if a.cancel != nil { - a.cancel() - } if a.redis != nil { _ = a.redis.Close() } - if a.events != nil { - _ = a.events.Close() - } if a.store != nil { _ = a.store.Close() } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b1e3ec8..9c44325 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "net/http" "strconv" "strings" @@ -24,22 +23,18 @@ var ( ) type Principal struct { - TenantID int64 - TenantCode string - UserID int64 - DeviceID string - AuthLevel string + TenantID int64 + MemberID int64 + DeviceID 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"` + MemberID int64 `json:"member_id"` + TenantID int64 `json:"tenant_id"` + DeviceID string `json:"device_id"` + State string `json:"state"` + ExpiresAt string `json:"expires_at"` + IssuedAt string `json:"issued_at"` } type Authenticator struct { @@ -51,23 +46,20 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator { return &Authenticator{cfg: cfg, redis: redisClient} } +func (a *Authenticator) DefaultTenantID() int64 { + return a.cfg.DefaultTenantID +} + func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) { - if err := a.validateAPIKey(req); err != nil { + if err := a.ValidateAPIKey(req); err != nil { return nil, err } if a.cfg.AllowDevHeaderAuth { - if principal := principalFromDevHeaders(req); principal != nil { - return principal, nil + if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 { + return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID, + DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, 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 } @@ -79,50 +71,60 @@ func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) { if err != nil { return nil, err } - if session.UserID <= 0 || session.TenantID <= 0 { + if session.MemberID <= 0 || session.TenantID < 0 || session.State != "active" { return nil, ErrAuthRequired } - if state := strings.ToLower(strings.TrimSpace(session.State)); state != "" && state != "active" { + expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { return nil, ErrAuthRequired } - if session.ExpiresAt != "" { - expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt) - if err == nil && time.Now().After(expiresAt) { - return nil, ErrAuthRequired - } + if err := a.validateMemberSession(req.Context(), session); err != nil { + return nil, err } - 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 + return &Principal{TenantID: session.TenantID, MemberID: session.MemberID, DeviceID: session.DeviceID}, nil } -func (a *Authenticator) validateAPIKey(req *http.Request) error { +func (a *Authenticator) Logout(req *http.Request) error { + if _, err := a.Authenticate(req); err != nil { + return err + } + token, err := bearerToken(req) + if err != nil { + return err + } + key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token) + if err != nil { + return err + } + if a.redis == nil { + return ErrAuthRequired + } + return a.redis.Del(req.Context(), key).Err() +} + +func (a *Authenticator) RevokeMemberSessions(ctx context.Context, tenantID, memberID int64) error { + if a == nil || a.redis == nil { + return ErrAuthRequired + } + key := memberRevokedBeforeKey(tenantID, memberID) + value := time.Now().UTC().Format(time.RFC3339Nano) + ttl := time.Duration(a.cfg.SessionTTLSeconds) * time.Second + return a.redis.Set(ctx, key, value, ttl).Err() +} + +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")) + got := strings.TrimSpace(req.Header.Get("X-API-Key")) if got == "" { got = strings.TrimSpace(req.URL.Query().Get("apikey")) } if got == "" { - if a.cfg.RequiredAPIKey { - return ErrForbidden - } - return nil + return ErrForbidden } if len(a.cfg.APIKeys) == 0 { - return nil + return ErrForbidden } for _, key := range a.cfg.APIKeys { if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 { @@ -133,14 +135,12 @@ func (a *Authenticator) validateAPIKey(req *http.Request) error { } 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 + key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token) + if err != nil { + return nil, ErrAuthRequired } - 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) { + if errors.Is(err, redis.Nil) { return nil, ErrAuthRequired } if err != nil { @@ -153,46 +153,46 @@ func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Se return &session, nil } +func (a *Authenticator) validateMemberSession(ctx context.Context, session *Session) error { + revokedBefore, err := a.redis.Get(ctx, memberRevokedBeforeKey(session.TenantID, session.MemberID)).Result() + if errors.Is(err, redis.Nil) { + return nil + } + if err != nil { + return err + } + issuedAt, issuedErr := time.Parse(time.RFC3339Nano, session.IssuedAt) + revokedAt, revokedErr := time.Parse(time.RFC3339Nano, revokedBefore) + if issuedErr != nil || revokedErr != nil || !issuedAt.After(revokedAt) { + return ErrAuthRequired + } + return nil +} + +func memberRevokedBeforeKey(tenantID, memberID int64) string { + return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) +} + +func sessionKeyFromToken(pattern, token string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token)) + if err != nil || len(raw) != 32 { + return "", ErrAuthRequired + } + digest := sha256.Sum256(raw) + return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1), nil +} + func bearerToken(req *http.Request) (string, error) { - authz := strings.TrimSpace(req.Header.Get("Authorization")) - token, ok := strings.CutPrefix(authz, "Bearer ") + authorization := strings.TrimSpace(req.Header.Get("Authorization")) + token, ok := strings.CutPrefix(authorization, "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 { +func parseInt64Header(req *http.Request, key string) int64 { value := strings.TrimSpace(req.Header.Get(key)) - if value == "" { - return fallback - } - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return fallback - } + parsed, _ := strconv.ParseInt(value, 10, 64) return parsed } - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..0461871 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,27 @@ +package auth + +import ( + "encoding/base64" + "errors" + "strings" + "testing" +) + +func TestSessionKeyFromToken(t *testing.T) { + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + key, err := sessionKeyFromToken("workbench:session:token:%s", token) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 { + t.Fatalf("unexpected session key %q", key) + } +} + +func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) { + for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} { + if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) { + t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3660f1a..fe4bda0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,12 +15,8 @@ import ( type Config struct { Listen string `json:"listen"` MySQL MySQLConfig `json:"mysql"` - CoreMySQL MySQLConfig `json:"core_mysql"` Redis RedisConfig `json:"redis"` - Kafka KafkaConfig `json:"kafka"` Auth AuthConfig `json:"auth"` - Manager ManagerConfig `json:"manager"` - AI AIConfig `json:"ai"` ReadTimeout time.Duration `json:"-"` WriteTimeout time.Duration `json:"-"` } @@ -32,65 +28,26 @@ type MySQLConfig struct { } 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"` + Addr string `json:"addr"` + Username string `json:"username"` + Password string `json:"password"` + DB int `json:"db"` } type AuthConfig struct { - APIKeys []string `json:"api_keys"` - SessionKeyPattern string `json:"session_key_pattern"` - UIDKey string `json:"uid_key"` - AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` - RequiredTenantCode bool `json:"required_tenant_code"` - RequiredAPIKey bool `json:"required_api_key"` - SkipSessionInDev bool `json:"skip_session_in_dev"` - TrustedInternalAddr string `json:"trusted_internal_addr"` - InternalServiceName string `json:"internal_service_name"` - InternalSecret string `json:"internal_secret"` -} - -type ManagerConfig struct { - BaseURL string `json:"base_url"` - ServiceName string `json:"service_name"` - Secret string `json:"secret"` - UIDKeyGroup string `json:"uid_key_group"` - UIDKeyKeys []string `json:"uid_key_keys"` - TimeoutMS int `json:"timeout_ms"` -} - -type AIConfig struct { - Provider string `json:"provider"` - DeepSeek DeepSeekConfig `json:"deepseek"` - DefaultModel string `json:"default_model"` - ComplexModel string `json:"complex_model"` - Temperature float64 `json:"temperature"` - MaxOutputTokens int `json:"max_output_tokens"` -} - -type DeepSeekConfig struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - TimeoutMS int `json:"timeout_ms"` - MaxRetries int `json:"max_retries"` + APIKeys []string `json:"api_keys"` + SessionKeyPattern string `json:"session_key_pattern"` + SessionTTLSeconds int `json:"session_ttl_seconds"` + DefaultTenantID int64 `json:"default_tenant_id"` + DataEncryptionKey string `json:"data_encryption_key"` + LookupHMACKey string `json:"lookup_hmac_key"` + AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` + RequiredAPIKey bool `json:"required_api_key"` } func Load() (*Config, error) { cfg := defaultConfig() - if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" { + if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" { data, err := os.ReadFile(path) if err != nil { return nil, err @@ -110,41 +67,16 @@ func defaultConfig() *Config { return &Config{ Listen: ":6061", MySQL: MySQLConfig{ - DSN: "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local", + DSN: "root:root@tcp(127.0.0.1:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local", MaxOpenConns: 15, MaxIdleConns: 5, }, - Redis: RedisConfig{ - Addr: "127.0.0.1:6379", - KeyPrefix: "im:", - }, - Kafka: KafkaConfig{ - Topic: "im.workbench.event", - ClientID: "im_workbench", - }, + Redis: RedisConfig{Addr: "127.0.0.1:6379"}, Auth: AuthConfig{ - SessionKeyPattern: "im:session:token:%s", - UIDKey: "dkXxWLwqUNRfYmdXRzOkZw==", - RequiredTenantCode: true, - RequiredAPIKey: true, - }, - Manager: ManagerConfig{ - ServiceName: "im-workbench", - UIDKeyGroup: "im", - UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, - TimeoutMS: 3000, - }, - AI: AIConfig{ - Provider: "deepseek", - DefaultModel: "deepseek-flash", - ComplexModel: "deepseek-v4-pro", - Temperature: 0.2, - MaxOutputTokens: 4096, - DeepSeek: DeepSeekConfig{ - BaseURL: "https://api.deepseek.com", - TimeoutMS: 60000, - MaxRetries: 2, - }, + SessionKeyPattern: "workbench:session:token:%s", + SessionTTLSeconds: 14 * 24 * 60 * 60, + DefaultTenantID: 0, + RequiredAPIKey: true, }, ReadTimeout: 8 * time.Second, WriteTimeout: 8 * time.Second, @@ -152,90 +84,24 @@ func defaultConfig() *Config { } func applyEnv(cfg *Config) { - setString(&cfg.Listen, "IM_WORKBENCH_LISTEN") - setString(&cfg.MySQL.DSN, "IM_WORKBENCH_MYSQL_DSN") - setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS") - setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS") - setString(&cfg.CoreMySQL.DSN, "IM_WORKBENCH_CORE_MYSQL_DSN") - setInt(&cfg.CoreMySQL.MaxOpenConns, "IM_WORKBENCH_CORE_MYSQL_MAX_OPEN_CONNS") - setInt(&cfg.CoreMySQL.MaxIdleConns, "IM_WORKBENCH_CORE_MYSQL_MAX_IDLE_CONNS") - if strings.TrimSpace(cfg.CoreMySQL.DSN) == "" { - cfg.CoreMySQL.DSN = cfg.MySQL.DSN - } - if cfg.CoreMySQL.MaxOpenConns == 0 { - cfg.CoreMySQL.MaxOpenConns = cfg.MySQL.MaxOpenConns - } - if cfg.CoreMySQL.MaxIdleConns == 0 { - cfg.CoreMySQL.MaxIdleConns = cfg.MySQL.MaxIdleConns - } - setString(&cfg.Redis.Addr, "IM_WORKBENCH_REDIS_ADDR") - setString(&cfg.Redis.Username, "IM_WORKBENCH_REDIS_USERNAME") - setString(&cfg.Redis.Password, "IM_WORKBENCH_REDIS_PASSWORD") - setString(&cfg.Redis.KeyPrefix, "IM_WORKBENCH_REDIS_KEY_PREFIX") - setString(&cfg.Kafka.Topic, "IM_WORKBENCH_KAFKA_TOPIC") - setString(&cfg.Kafka.Username, "IM_WORKBENCH_KAFKA_USERNAME") - setString(&cfg.Kafka.Password, "IM_WORKBENCH_KAFKA_PASSWORD") - setString(&cfg.Kafka.SASL, "IM_WORKBENCH_KAFKA_SASL") - setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID") - setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN") - setString(&cfg.Auth.UIDKey, "IM_WORKBENCH_UID_KEY") - setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR") - setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME") - setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET") - setString(&cfg.Manager.BaseURL, "IM_WORKBENCH_MANAGER_BASE_URL") - setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME") - setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET") - setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP") - setString(&cfg.AI.Provider, "IM_WORKBENCH_AI_PROVIDER") - setString(&cfg.AI.DeepSeek.BaseURL, "DEEPSEEK_BASE_URL") - setString(&cfg.AI.DeepSeek.APIKey, "DEEPSEEK_API_KEY") - setString(&cfg.AI.DefaultModel, "DEEPSEEK_DEFAULT_MODEL") - setString(&cfg.AI.ComplexModel, "DEEPSEEK_COMPLEX_MODEL") - setInt(&cfg.AI.DeepSeek.TimeoutMS, "DEEPSEEK_TIMEOUT_MS") - setInt(&cfg.AI.DeepSeek.MaxRetries, "DEEPSEEK_MAX_RETRIES") - setInt(&cfg.AI.MaxOutputTokens, "DEEPSEEK_MAX_OUTPUT_TOKENS") - setFloat(&cfg.AI.Temperature, "DEEPSEEK_TEMPERATURE") - if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { - setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET") - } - if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { - setString(&cfg.Auth.InternalSecret, "IM_ADMIN_API_SECRET") - } - if strings.TrimSpace(cfg.Manager.Secret) == "" { - setString(&cfg.Manager.Secret, "IM_CONFIG_API_SECRET") - } - if strings.TrimSpace(cfg.Manager.Secret) == "" { - setString(&cfg.Manager.Secret, "IM_INTERNAL_API_SECRET") - } - if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" { - cfg.Kafka.Brokers = splitCSV(brokers) - } - if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_API_KEYS")); keys != "" { + setString(&cfg.Listen, "WORKBENCH_LISTEN") + setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN") + setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS") + setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS") + setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR") + setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME") + setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD") + setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB") + setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN") + setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS") + setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID") + setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY") + setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY") + setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH") + setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY") + if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" { cfg.Auth.APIKeys = splitCSV(keys) } - if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_UID_KEY_KEYS")); keys != "" { - cfg.Manager.UIDKeyKeys = splitCSV(keys) - } - if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BATCH_SIZE")); value != "" { - if parsed, err := strconv.Atoi(value); err == nil { - cfg.Kafka.BatchSize = parsed - } - } - if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_TIMEOUT_MS")); value != "" { - if parsed, err := strconv.Atoi(value); err == nil { - cfg.Manager.TimeoutMS = parsed - } - } - if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_REDIS_DB")); value != "" { - if parsed, err := strconv.Atoi(value); err == nil { - cfg.Redis.DB = parsed - } - } - setBool(&cfg.Auth.AllowDevHeaderAuth, "IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH") - setBool(&cfg.Auth.RequiredTenantCode, "IM_WORKBENCH_REQUIRED_TENANT_CODE") - setBool(&cfg.Auth.RequiredAPIKey, "IM_WORKBENCH_REQUIRED_API_KEY") - setBool(&cfg.Auth.SkipSessionInDev, "IM_WORKBENCH_SKIP_SESSION_IN_DEV") - setBool(&cfg.Kafka.Enabled, "IM_WORKBENCH_KAFKA_ENABLED") } func (c *Config) validate() error { @@ -245,47 +111,24 @@ func (c *Config) validate() error { if strings.TrimSpace(c.MySQL.DSN) == "" { return errors.New("mysql dsn is required") } - mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://") - parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN) + parsed, err := mysqlDriver.ParseDSN(strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://")) if err != nil { return fmt.Errorf("mysql dsn is invalid: %w", err) } - if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") { - return errors.New("mysql database tinode is deprecated; use workbench") + if !strings.EqualFold(strings.TrimSpace(parsed.DBName), "olons_admin") { + return errors.New("mysql database must be olons_admin") } - coreMySQLDSN := strings.TrimPrefix(strings.TrimSpace(c.CoreMySQL.DSN), "mysql://") - if _, err := mysqlDriver.ParseDSN(coreMySQLDSN); err != nil { - return fmt.Errorf("core mysql dsn is invalid: %w", err) - } - if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev { + if strings.TrimSpace(c.Redis.Addr) == "" { 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 errors.New("session key pattern must include %s") } - if strings.TrimSpace(c.AI.Provider) == "" { - c.AI.Provider = "deepseek" + if c.Auth.SessionTTLSeconds <= 0 { + c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60 } - if strings.TrimSpace(c.AI.DeepSeek.BaseURL) == "" { - c.AI.DeepSeek.BaseURL = "https://api.deepseek.com" - } - if c.AI.DeepSeek.TimeoutMS <= 0 { - c.AI.DeepSeek.TimeoutMS = 60000 - } - if strings.TrimSpace(c.AI.DefaultModel) == "" { - c.AI.DefaultModel = "deepseek-flash" - } - if strings.TrimSpace(c.AI.ComplexModel) == "" { - c.AI.ComplexModel = c.AI.DefaultModel - } - if c.AI.Temperature == 0 { - c.AI.Temperature = 0.2 - } - if c.AI.MaxOutputTokens <= 0 { - c.AI.MaxOutputTokens = 4096 + if c.Auth.DefaultTenantID < 0 { + return errors.New("default tenant id must not be negative") } return nil } @@ -304,6 +147,14 @@ func setInt(target *int, key string) { } } +func setInt64(target *int64, key string) { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { + *target = parsed + } + } +} + func setBool(target *bool, key string) { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { case "1", "true", "yes", "on": @@ -313,14 +164,6 @@ func setBool(target *bool, key string) { } } -func setFloat(target *float64, key string) { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - if parsed, err := strconv.ParseFloat(value, 64); err == nil { - *target = parsed - } - } -} - func splitCSV(value string) []string { parts := strings.Split(value, ",") items := make([]string, 0, len(parts)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4cbb5ba..cdf1094 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,100 +1,21 @@ package config -import ( - "os" - "path/filepath" - "strings" - "testing" -) +import "testing" -func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) { - cfg := defaultConfig() - cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true" - - err := cfg.validate() - if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") { - t.Fatalf("validate() error = %v, want deprecated database error", err) - } -} - -func TestDefaultConfigUsesWorkbenchDatabase(t *testing.T) { +func TestDefaultConfigUsesOlonsAdmin(t *testing.T) { cfg := defaultConfig() if err := cfg.validate(); err != nil { - t.Fatalf("default config validation failed: %v", err) + t.Fatal(err) } - if !strings.Contains(cfg.MySQL.DSN, "/workbench?") { - t.Fatalf("default mysql dsn = %q, want workbench database", cfg.MySQL.DSN) - } - if cfg.MySQL.MaxOpenConns != 15 || cfg.MySQL.MaxIdleConns != 5 { - t.Fatalf("default mysql pool = %d/%d, want 15/5", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns) + if cfg.Auth.DefaultTenantID != 0 { + t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID) } } -func TestApplyEnvOverridesMySQLPool(t *testing.T) { - t.Setenv("IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS", "12") - t.Setenv("IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS", "4") +func TestConfigRejectsOtherDatabase(t *testing.T) { cfg := defaultConfig() - - applyEnv(cfg) - - if cfg.MySQL.MaxOpenConns != 12 || cfg.MySQL.MaxIdleConns != 4 { - t.Fatalf("mysql pool = %d/%d, want 12/4", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns) - } -} - -func TestLoadFallsBackCoreMySQLToPrimaryDSN(t *testing.T) { - path := filepath.Join(t.TempDir(), "workbench.json") - if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"}}`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("IM_WORKBENCH_CONFIG", path) - t.Setenv("IM_WORKBENCH_MYSQL_DSN", "") - t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.CoreMySQL.DSN != cfg.MySQL.DSN { - t.Fatalf("core_mysql.dsn = %q, want primary mysql.dsn %q", cfg.CoreMySQL.DSN, cfg.MySQL.DSN) - } -} - -func TestLoadUsesCoreMySQLDSNFromEnvironment(t *testing.T) { - path := filepath.Join(t.TempDir(), "workbench.json") - if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"}}`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("IM_WORKBENCH_CONFIG", path) - t.Setenv("IM_WORKBENCH_MYSQL_DSN", "") - t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if !strings.Contains(cfg.CoreMySQL.DSN, "/meeting_im?") { - t.Fatalf("core_mysql.dsn = %q, want meeting_im", cfg.CoreMySQL.DSN) - } -} - -func TestLoadPreservesCoreMySQLDSNFromJSON(t *testing.T) { - path := filepath.Join(t.TempDir(), "workbench.json") - if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"},"core_mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true"}}`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("IM_WORKBENCH_CONFIG", path) - t.Setenv("IM_WORKBENCH_MYSQL_DSN", "") - t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if !strings.Contains(cfg.MySQL.DSN, "/workbench?") { - t.Fatalf("mysql.dsn = %q, want workbench", cfg.MySQL.DSN) - } - if !strings.Contains(cfg.CoreMySQL.DSN, "/meeting_im?") { - t.Fatalf("core_mysql.dsn = %q, want meeting_im", cfg.CoreMySQL.DSN) + cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/other_database?parseTime=true" + if err := cfg.validate(); err == nil { + t.Fatal("expected non-olons_admin database to be rejected") } } diff --git a/internal/events/events.go b/internal/events/events.go deleted file mode 100644 index c40db22..0000000 --- a/internal/events/events.go +++ /dev/null @@ -1,180 +0,0 @@ -package events - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/auth" - "git.118im.com/im_team/im_workbench/internal/config" - "github.com/segmentio/kafka-go" - "github.com/segmentio/kafka-go/sasl" - "github.com/segmentio/kafka-go/sasl/scram" -) - -type Event struct { - EventID string `json:"event_id"` - TraceID string `json:"trace_id,omitempty"` - TenantID int64 `json:"tenant_id"` - TenantCode string `json:"tenant_code,omitempty"` - ActorUserID int64 `json:"actor_user_id"` - Module string `json:"module"` - Action string `json:"action"` - ResourceID string `json:"resource_id"` - TargetUserIDs []int64 `json:"target_user_ids,omitempty"` - Title string `json:"title,omitempty"` - Summary string `json:"summary,omitempty"` - OccurredAt time.Time `json:"occurred_at"` - Data map[string]any `json:"data,omitempty"` -} - -type Publisher interface { - Publish(context.Context, Event) error - Close() error -} - -type NoopPublisher struct{} - -func (NoopPublisher) Publish(context.Context, Event) error { return nil } -func (NoopPublisher) Close() error { return nil } - -type KafkaPublisher struct { - writer *kafka.Writer - topic string -} - -func NewKafkaPublisher(cfg config.KafkaConfig) (Publisher, error) { - if !cfg.Enabled || len(cfg.Brokers) == 0 { - return NoopPublisher{}, nil - } - topic := strings.TrimSpace(cfg.Topic) - if topic == "" { - topic = "im.workbench.event" - } - transport := &kafka.Transport{} - if mechanism, err := saslMechanism(cfg); err != nil { - return nil, err - } else if mechanism != nil { - transport.SASL = mechanism - } - writer := &kafka.Writer{ - Addr: kafka.TCP(cfg.Brokers...), - Topic: topic, - Balancer: &kafka.Hash{}, - Transport: transport, - BatchSize: cfg.BatchSize, - RequiredAcks: kafka.RequireAll, - Async: false, - } - if writer.BatchSize <= 0 { - writer.BatchSize = 1 - } - return &KafkaPublisher{writer: writer, topic: topic}, nil -} - -func (p *KafkaPublisher) Publish(ctx context.Context, event Event) error { - if p == nil || p.writer == nil { - return nil - } - if strings.TrimSpace(event.EventID) == "" { - event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID) - } - if event.OccurredAt.IsZero() { - event.OccurredAt = time.Now().UTC().Round(time.Millisecond) - } - data, err := json.Marshal(event) - if err != nil { - return err - } - key := []byte(event.TenantCode + ":" + event.ResourceID) - if len(key) == 1 { - key = []byte(event.EventID) - } - return p.writer.WriteMessages(ctx, kafka.Message{ - Key: key, - Value: data, - Headers: []kafka.Header{ - {Key: "event_id", Value: []byte(event.EventID)}, - {Key: "event_type", Value: []byte(event.Action)}, - {Key: "tenant_code", Value: []byte(event.TenantCode)}, - }, - Time: event.OccurredAt, - }) -} - -func (p *KafkaPublisher) Close() error { - if p == nil || p.writer == nil { - return nil - } - return p.writer.Close() -} - -func NewWorkbenchEvent(p *auth.Principal, traceID, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) Event { - event := Event{ - TraceID: strings.TrimSpace(traceID), - TenantID: p.TenantID, - TenantCode: p.TenantCode, - ActorUserID: p.UserID, - Module: strings.TrimSpace(module), - Action: strings.TrimSpace(action), - ResourceID: strings.TrimSpace(resourceID), - TargetUserIDs: compactInt64s(targets), - Title: strings.TrimSpace(title), - Summary: strings.TrimSpace(summary), - OccurredAt: time.Now().UTC().Round(time.Millisecond), - Data: data, - } - event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID) - return event -} - -func StableEventID(tenantID int64, module, action, resourceID string, actorUserID int64) string { - raw := strings.Join([]string{ - int64String(tenantID), - strings.TrimSpace(module), - strings.TrimSpace(action), - strings.TrimSpace(resourceID), - int64String(actorUserID), - }, ":") - sum := sha256.Sum256([]byte(raw)) - return "wbe_" + hex.EncodeToString(sum[:])[:32] -} - -func saslMechanism(cfg config.KafkaConfig) (sasl.Mechanism, error) { - if strings.TrimSpace(cfg.Username) == "" { - return nil, nil - } - switch strings.ToUpper(strings.TrimSpace(cfg.SASL)) { - case "", "SCRAM-SHA-512": - return scram.Mechanism(scram.SHA512, cfg.Username, cfg.Password) - case "SCRAM-SHA-256": - return scram.Mechanism(scram.SHA256, cfg.Username, cfg.Password) - default: - return nil, errors.New("unsupported kafka sasl mechanism") - } -} - -func compactInt64s(values []int64) []int64 { - if len(values) == 0 { - return nil - } - seen := make(map[int64]bool, len(values)) - items := make([]int64, 0, len(values)) - for _, value := range values { - if value <= 0 || seen[value] { - continue - } - seen[value] = true - items = append(items, value) - } - return items -} - -func int64String(value int64) string { - return strconv.FormatInt(value, 10) -} diff --git a/internal/finance/errors.go b/internal/finance/errors.go new file mode 100644 index 0000000..8619c55 --- /dev/null +++ b/internal/finance/errors.go @@ -0,0 +1,19 @@ +package finance + +import "errors" + +var ( + ErrUnavailable = errors.New("finance service unavailable") + ErrAccountTypeInvalid = errors.New("payment account type invalid") + ErrAccountFieldsInvalid = errors.New("payment account fields invalid") + ErrTradePassword = errors.New("trade password invalid") + ErrTradePasswordUnset = errors.New("trade password is not set") + ErrRateLimited = errors.New("trade password attempts rate limited") + ErrCurrencyInvalid = errors.New("currency invalid") + ErrAmountInvalid = errors.New("amount invalid") + ErrIdempotencyKey = errors.New("idempotency key invalid") + ErrPageInvalid = errors.New("page invalid") + ErrPageSizeInvalid = errors.New("page size invalid") + ErrStatusInvalid = errors.New("withdrawal status invalid") + ErrOrderIDInvalid = errors.New("withdrawal order id invalid") +) diff --git a/internal/finance/service.go b/internal/finance/service.go new file mode 100644 index 0000000..fdd2d0b --- /dev/null +++ b/internal/finance/service.go @@ -0,0 +1,737 @@ +package finance + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "git.118im.com/im_team/im_workbench/internal/config" + "git.118im.com/im_team/im_workbench/internal/store" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 + quoteTTL = 5 * time.Minute + passwordMaxTry = 5 +) + +var ( + tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) + phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`) + bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`) + accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`) + idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`) +) + +type Repository interface { + CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) + AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) + PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) + CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) + PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) + ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) + WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) + CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) + WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) + WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) +} + +type Service struct { + repository Repository + redis *redis.Client + aead cipher.AEAD + lookupKey []byte + ready bool + now func() time.Time +} + +type PaymentAccountInput struct { + AccountType string `json:"account_type"` + BankCode string `json:"bank_code"` + BankName string `json:"bank_name"` + HolderName string `json:"holder_name"` + AccountNo string `json:"account_no"` + ReservedPhone string `json:"reserved_phone"` + QRAttachmentID int64 `json:"qr_attachment_id"` + TradePassword string `json:"trade_password"` +} + +type PaymentAccount struct { + ID int64 `json:"id"` + AccountType string `json:"account_type"` + BankCode string `json:"bank_code"` + BankName string `json:"bank_name"` + HolderMask string `json:"holder_mask"` + MobileMask string `json:"mobile_mask"` + AccountNoMask string `json:"account_no_mask"` + QRAttachmentID int64 `json:"qr_attachment_id"` + VerifyStatus string `json:"verify_status"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type QuoteInput struct { + PaymentAccountID int64 `json:"payment_account_id"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` +} + +type Quote struct { + PaymentAccountID int64 `json:"payment_account_id"` + AccountMask string `json:"account_mask"` + ChannelID int64 `json:"channel_id"` + ChannelCode string `json:"channel_code"` + ChannelName string `json:"channel_name"` + RequestAmount int64 `json:"request_amount"` + FeeAmount int64 `json:"fee_amount"` + ActualAmount int64 `json:"actual_amount"` + Currency string `json:"currency"` + MinimumAmount int64 `json:"minimum_amount"` + MaximumAmount int64 `json:"maximum_amount"` + AvailableAmount int64 `json:"available_amount"` + TodayCount int `json:"today_count"` + DailyLimit int `json:"daily_limit"` + QuoteExpiresAt string `json:"quote_expires_at"` +} + +type CreateWithdrawalInput struct { + PaymentAccountID int64 `json:"payment_account_id"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + TradePassword string `json:"trade_password"` +} + +type Withdrawal struct { + ID int64 `json:"id"` + WithdrawalNo string `json:"withdrawal_no"` + PaymentAccountID int64 `json:"payment_account_id"` + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + RequestAmount int64 `json:"request_amount"` + FeeAmount int64 `json:"fee_amount"` + ActualAmount int64 `json:"actual_amount"` + Currency string `json:"currency"` + AccountMask string `json:"account_mask"` + BankName string `json:"bank_name"` + HolderMask string `json:"holder_mask"` + Status string `json:"status"` + StatusText string `json:"status_text"` + ReasonCode string `json:"reason_code"` + ReasonText string `json:"reason_text"` + ExternalNo string `json:"external_no,omitempty"` + RiskResult json.RawMessage `json:"risk_result,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ProcessedAt string `json:"processed_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + IdempotentReplay bool `json:"idempotent_replay,omitempty"` +} + +type WithdrawalPage struct { + Items []Withdrawal `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` +} + +func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { + service := &Service{repository: repository, redis: redisClient, now: time.Now} + encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) + lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey) + if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { + return service + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return service + } + aead, err := cipher.NewGCM(block) + if err != nil { + return service + } + service.aead = aead + service.lookupKey = lookupKey + service.ready = repository != nil && redisClient != nil + return service +} + +func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { + if !s.ready { + return nil, ErrUnavailable + } + rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + items := make([]PaymentAccount, 0, len(rows)) + for _, row := range rows { + items = append(items, paymentAccountFromStore(row)) + } + return items, nil +} + +func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { + if !s.ready { + return nil, ErrUnavailable + } + accountType, err := normalizePaymentAccount(&input) + if err != nil { + return nil, err + } + security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword) + if err != nil { + return nil, err + } + holderCipher, err := s.encrypt(input.HolderName) + if err != nil { + return nil, err + } + accountCipher, err := s.encrypt(input.AccountNo) + if err != nil { + return nil, err + } + mobileCipher := "" + if input.ReservedPhone != "" { + mobileCipher, err = s.encrypt("+86" + input.ReservedPhone) + if err != nil { + return nil, err + } + } + row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{ + TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash, + AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName, + HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher, + MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher, + AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), + QRAttachmentID: input.QRAttachmentID, + }) + if err != nil { + return nil, err + } + result := paymentAccountFromStore(*row) + return &result, nil +} + +func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { + if !s.ready { + return nil, ErrUnavailable + } + if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil { + return nil, err + } + return s.quote(ctx, tenantID, memberID, input) +} + +func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64, idempotencyKey string, input CreateWithdrawalInput) (*Withdrawal, error) { + if !s.ready { + return nil, ErrUnavailable + } + idempotencyKey = strings.TrimSpace(idempotencyKey) + if !idempotencyPattern.MatchString(idempotencyKey) { + return nil, ErrIdempotencyKey + } + if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil { + return nil, err + } + security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword) + if err != nil { + return nil, err + } + quote, account, channel, err := s.quoteData(ctx, tenantID, memberID, QuoteInput{ + PaymentAccountID: input.PaymentAccountID, Amount: input.Amount, Currency: input.Currency}) + if err != nil { + return nil, err + } + holderName, err := s.decrypt(account.HolderCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + accountNo, err := s.decrypt(account.AccountNoCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + reservedPhone := "" + if account.MobileCipher != "" { + reservedPhone, err = s.decrypt(account.MobileCipher) + if err != nil { + return nil, store.ErrPaymentAccountInvalid + } + } + snapshot, err := json.Marshal(map[string]any{ + "account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode, + "bank_name": account.BankName, "holder_name": holderName, + "reserved_phone": reservedPhone, "account_no": accountNo, + "qr_attachment_id": account.QRAttachmentID, + }) + if err != nil { + return nil, err + } + snapshotCipher, err := s.encrypt(string(snapshot)) + if err != nil { + return nil, err + } + withdrawalNo := deterministicNumber("WD", tenantID, memberID, idempotencyKey) + row, err := s.repository.CreateWithdrawal(ctx, store.WithdrawalCreation{ + TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash, + WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID, + ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode, + ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher, + ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher, + ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID, + Channel: *channel, + RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount, + Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask, + BankName: account.BankName, HolderMask: account.HolderMask, + AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey), + FrozenLedgerNo: deterministicNumber("LF", tenantID, memberID, idempotencyKey), + }) + if err != nil { + return nil, err + } + result := withdrawalFromStore(*row) + return &result, nil +} + +func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) { + if !s.ready { + return nil, ErrUnavailable + } + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = defaultPageSize + } + if page < 1 { + return nil, ErrPageInvalid + } + if pageSize < 1 || pageSize > maxPageSize { + return nil, ErrPageSizeInvalid + } + statusCode, ok := withdrawalStatusCode(status) + if !ok { + return nil, ErrStatusInvalid + } + rows, err := s.repository.WithdrawalOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]Withdrawal, 0, len(rows.Items)) + for _, row := range rows.Items { + items = append(items, withdrawalFromStore(row)) + } + return &WithdrawalPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil +} + +func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) { + if !s.ready { + return nil, ErrUnavailable + } + if orderID <= 0 { + return nil, ErrOrderIDInvalid + } + row, err := s.repository.WithdrawalOrder(ctx, tenantID, memberID, orderID) + if err != nil { + return nil, err + } + result := withdrawalFromStore(*row) + return &result, nil +} + +func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { + quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input) + return quote, err +} + +func (s *Service) quoteData(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, *store.PaymentAccount, *store.WithdrawalChannel, error) { + input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) + if input.PaymentAccountID <= 0 { + return nil, nil, nil, store.ErrPaymentAccountInvalid + } + if input.Currency != "CNY" { + return nil, nil, nil, ErrCurrencyInvalid + } + if input.Amount <= 0 { + return nil, nil, nil, ErrAmountInvalid + } + account, err := s.repository.PaymentAccountForWithdrawal(ctx, tenantID, memberID, input.PaymentAccountID) + if err != nil { + return nil, nil, nil, err + } + if account.VerifyStatus != 2 { + return nil, nil, nil, store.ErrPaymentAccountInvalid + } + channel, err := s.repository.ActiveWithdrawalChannel(ctx, tenantID, channelTypes(account.AccountType)) + if err != nil { + return nil, nil, nil, err + } + wallet, err := s.repository.WithdrawalWallet(ctx, tenantID, memberID) + if err != nil { + return nil, nil, nil, err + } + if wallet.AccountStatus != 1 { + return nil, nil, nil, store.ErrAccountRestricted + } + minimum := channel.MinAmount + if minimum < 1 { + minimum = 1 + } + maximum := channel.MaxAmount + if maximum == 0 || maximum > wallet.AvailableAmount { + maximum = wallet.AvailableAmount + } + if input.Amount < minimum || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) { + return nil, nil, nil, store.ErrWithdrawalAmount + } + if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit { + return nil, nil, nil, store.ErrWithdrawalLimit + } + if input.Amount > wallet.AvailableAmount { + return nil, nil, nil, store.ErrInsufficientBalance + } + fee, err := withdrawalFee(input.Amount, *channel) + if err != nil || fee < 0 || fee >= input.Amount { + return nil, nil, nil, store.ErrWithdrawalAmount + } + return &Quote{PaymentAccountID: account.ID, AccountMask: account.AccountNoMask, + ChannelID: channel.ID, ChannelCode: channel.Code, ChannelName: channel.Name, + RequestAmount: input.Amount, FeeAmount: fee, ActualAmount: input.Amount - fee, + Currency: input.Currency, MinimumAmount: minimum, MaximumAmount: maximum, + AvailableAmount: wallet.AvailableAmount, TodayCount: wallet.TodayCount, DailyLimit: wallet.DailyLimit, + QuoteExpiresAt: s.now().UTC().Add(quoteTTL).Format(time.RFC3339)}, account, channel, nil +} + +func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) error { + raw, err := s.repository.CurrentSiteConfig(ctx, tenantID) + if err != nil { + return err + } + var config map[string]any + if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") { + return store.ErrWithdrawalDisabled + } + return nil +} + +func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID int64, password string) (*store.AccountSecurity, error) { + if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) { + return nil, ErrTradePassword + } + key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID) + attempts, err := s.redis.Get(ctx, key).Int() + if err != nil && !errors.Is(err, redis.Nil) { + return nil, ErrUnavailable + } + if attempts >= passwordMaxTry { + return nil, ErrRateLimited + } + security, err := s.repository.AccountSecurity(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + if security.TradePasswordHash == "" { + return nil, ErrTradePasswordUnset + } + if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(password)) != nil { + pipe := s.redis.TxPipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, 15*time.Minute) + if _, rateErr := pipe.Exec(ctx); rateErr != nil { + return nil, ErrUnavailable + } + return nil, ErrTradePassword + } + if err := s.redis.Del(ctx, key).Err(); err != nil { + return nil, ErrUnavailable + } + return security, nil +} + +func normalizePaymentAccount(input *PaymentAccountInput) (int, error) { + input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType)) + input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode)) + input.BankName = strings.TrimSpace(input.BankName) + input.HolderName = strings.TrimSpace(input.HolderName) + input.AccountNo = strings.TrimSpace(input.AccountNo) + input.ReservedPhone = strings.TrimSpace(input.ReservedPhone) + if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 { + return 0, ErrAccountFieldsInvalid + } + switch input.AccountType { + case "bank_card": + input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo) + if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 || + utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) { + return 0, ErrAccountFieldsInvalid + } + return 1, nil + case "digital_wallet": + if !accountTokenPattern.MatchString(input.AccountNo) { + return 0, ErrAccountFieldsInvalid + } + return 2, nil + case "unionpay": + if !accountTokenPattern.MatchString(input.AccountNo) { + return 0, ErrAccountFieldsInvalid + } + return 3, nil + case "alipay": + input.BankName = "支付宝" + input.BankCode = "ALIPAY" + if !accountTokenPattern.MatchString(input.AccountNo) { + return 0, ErrAccountFieldsInvalid + } + return 4, nil + case "wechat": + input.BankName = "微信" + input.BankCode = "WECHAT" + if !accountTokenPattern.MatchString(input.AccountNo) { + return 0, ErrAccountFieldsInvalid + } + return 5, nil + default: + return 0, ErrAccountTypeInvalid + } +} + +func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount { + return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode, + BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask, + AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID, + VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal { + risk := row.RiskResult + if len(risk) == 0 { + risk = json.RawMessage(`{}`) + } + return Withdrawal{ID: row.ID, WithdrawalNo: row.WithdrawalNo, PaymentAccountID: row.PaymentAccountID, + ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, + FeeAmount: row.FeeAmount, ActualAmount: row.ActualAmount, Currency: row.Currency, + AccountMask: row.AccountMask, BankName: row.BankName, HolderMask: row.HolderMask, + Status: withdrawalStatusName(row.Status), StatusText: withdrawalStatusText(row.Status), + ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, ExternalNo: row.ExternalNo, + RiskResult: risk, ReviewedAt: row.ReviewedAt, ProcessedAt: row.ProcessedAt, + CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + IdempotentReplay: row.Replayed} +} + +func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) { + if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 { + return 0, store.ErrWithdrawalChannel + } + rate := int64(channel.FeeRate) + rateFee := (amount/10000)*rate + ((amount%10000)*rate+9999)/10000 + switch channel.FeeType { + case 1: + return channel.FeeFixedAmount, nil + case 2: + return rateFee, nil + case 3: + return channel.FeeFixedAmount + rateFee, nil + default: + return 0, store.ErrWithdrawalChannel + } +} + +func channelTypes(accountType int) []int { + specific := map[int]int{1: 3, 2: 4, 3: 3, 4: 2, 5: 1}[accountType] + if specific == 0 || specific == 4 { + return []int{4} + } + return []int{specific, 4} +} + +func accountTypeName(value int) string { + names := map[int]string{1: "bank_card", 2: "digital_wallet", 3: "unionpay", 4: "alipay", 5: "wechat"} + if name := names[value]; name != "" { + return name + } + return "unknown" +} + +func verifyStatusName(value int) string { + names := map[int]string{1: "pending", 2: "verified", 3: "rejected"} + if name := names[value]; name != "" { + return name + } + return "unknown" +} + +func withdrawalStatusCode(value string) (int, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return 0, true + case "pending_review": + return 1, true + case "reviewing": + return 2, true + case "processing": + return 3, true + case "succeeded": + return 4, true + case "failed": + return 5, true + case "rejected": + return 6, true + case "cancelled": + return 7, true + default: + return 0, false + } +} + +func withdrawalStatusName(value int) string { + names := map[int]string{1: "pending_review", 2: "reviewing", 3: "processing", 4: "succeeded", 5: "failed", 6: "rejected", 7: "cancelled"} + if name := names[value]; name != "" { + return name + } + return "unknown" +} + +func withdrawalStatusText(value int) string { + names := map[int]string{1: "待审核", 2: "审核中", 3: "处理中", 4: "已成功", 5: "失败", 6: "已驳回", 7: "已取消"} + if name := names[value]; name != "" { + return name + } + return "未知状态" +} + +func (s *Service) encrypt(value string) (string, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + sealed := s.aead.Seal(nil, nonce, []byte(value), nil) + return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil +} + +func (s *Service) decrypt(value string) (string, error) { + payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(payload) <= s.aead.NonceSize() { + return "", errors.New("encrypted value is invalid") + } + plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (s *Service) lookupHash(value string) string { + mac := hmac.New(sha256.New, s.lookupKey) + _, _ = mac.Write([]byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func deterministicNumber(prefix string, tenantID, memberID int64, key string) string { + digest := sha256.Sum256([]byte(strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) + ":" + key + ":" + prefix)) + return prefix + strings.ToUpper(hex.EncodeToString(digest[:16])) +} + +func decodeKey(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.RawStdEncoding.DecodeString(value) +} + +func maskPhone(value string) string { + if len(value) != 11 { + return "" + } + return value[:3] + "****" + value[7:] +} + +func maskName(value string) string { + runes := []rune(value) + if len(runes) == 0 { + return "" + } + if len(runes) == 1 { + return string(runes[0]) + "*" + } + return string(runes[0]) + strings.Repeat("*", len(runes)-1) +} + +func maskAccount(value string) string { + runes := []rune(value) + if len(runes) <= 4 { + return strings.Repeat("*", len(runes)) + } + if len(runes) <= 6 { + return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1]) + } + prefix := 4 + if len(runes) < 12 { + prefix = 2 + } + return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:]) +} + +func validHumanName(value string) bool { + count := utf8.RuneCountInString(value) + if count < 2 || count > 64 { + return false + } + for _, r := range value { + if unicode.IsControl(r) || r == '<' || r == '>' { + return false + } + } + return true +} + +func allDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return value != "" +} + +func findBool(config map[string]any, keys ...string) bool { + for _, key := range keys { + for candidate, value := range config { + if strings.EqualFold(candidate, key) { + switch typed := value.(type) { + case bool: + return typed + case float64: + return typed == 1 + case string: + return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") + } + } + } + } + for _, value := range config { + if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) { + return true + } + } + return false +} diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go new file mode 100644 index 0000000..08817e2 --- /dev/null +++ b/internal/finance/service_test.go @@ -0,0 +1,128 @@ +package finance + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" + "git.118im.com/im_team/im_workbench/internal/store" +) + +type fakeRepository struct { + config json.RawMessage + account *store.PaymentAccount + channel *store.WithdrawalChannel + wallet *store.WithdrawalWallet + orders []store.WithdrawalOrder +} + +func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { + return f.config, nil +} +func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) { + return nil, errors.New("not implemented") +} +func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) { + return nil, nil +} +func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) { + return nil, errors.New("not implemented") +} +func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) { + return f.account, nil +} +func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) { + return f.channel, nil +} +func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) { + return f.wallet, nil +} +func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) { + return nil, errors.New("not implemented") +} +func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) { + return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil +} +func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) { + return nil, errors.New("not implemented") +} + +func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`), + account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2}, + channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3, + MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25}, + wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}} + service := &Service{repository: repository, ready: true, now: func() time.Time { + return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) + }} + + result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"}) + if err != nil { + t.Fatal(err) + } + if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 || + result.QuoteExpiresAt != "2026-10-09T02:05:00Z" { + t.Fatalf("unexpected quote: %#v", result) + } +} + +func TestQuoteFailsClosed(t *testing.T) { + service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now} + _, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"}) + if !errors.Is(err, store.ErrWithdrawalDisabled) { + t.Fatalf("expected disabled error, got %v", err) + } +} + +func TestNormalizePaymentAccount(t *testing.T) { + input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行", + HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"} + accountType, err := normalizePaymentAccount(&input) + if err != nil { + t.Fatal(err) + } + if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" || + maskAccount(input.AccountNo) != "6222********1234" { + t.Fatalf("unexpected normalized input: %#v", input) + } +} + +func TestMaskAccountShortValueDoesNotLeak(t *testing.T) { + if got := maskAccount("abcde"); got != "a***e" { + t.Fatalf("unexpected mask %q", got) + } +} + +func TestWithdrawalStatusValidation(t *testing.T) { + service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now} + _, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad") + if !errors.Is(err, ErrStatusInvalid) { + t.Fatalf("expected status error, got %v", err) + } +} + +func TestDeterministicNumberIsStableAndScoped(t *testing.T) { + first := deterministicNumber("WD", 0, 1, "request-key") + if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") { + t.Fatal("deterministic number is not stable or member scoped") + } +} + +func TestEncryptionRoundTrip(t *testing.T) { + service := New(&fakeRepository{}, nil, config.AuthConfig{ + DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=", + }) + ciphertext, err := service.encrypt("6222000000001234") + if err != nil { + t.Fatal(err) + } + plain, err := service.decrypt(ciphertext) + if err != nil || plain != "6222000000001234" { + t.Fatalf("unexpected round trip: %q, %v", plain, err) + } +} diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go new file mode 100644 index 0000000..a295e8b --- /dev/null +++ b/internal/httpapi/accounts.go @@ -0,0 +1,144 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + accountsvc "git.118im.com/im_team/im_workbench/internal/accounts" + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/store" +) + +type accountRegisterRequest struct { + Username string `json:"username"` + Phone string `json:"phone"` + Password string `json:"password"` + ReferralCode string `json:"referral_code,omitempty"` + PrivacyAccepted bool `json:"privacy_accepted"` +} + +type accountLoginRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` +} + +func (a *API) accountRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + if !a.validatePublicClient(w, r) { + return + } + var body accountRegisterRequest + if !decodeBody(w, r, &body) { + return + } + result, err := a.accounts.Register(r.Context(), accountsvc.RegisterInput{ + Username: body.Username, Phone: body.Phone, Password: body.Password, + ReferralCode: body.ReferralCode, PrivacyAccepted: body.PrivacyAccepted, + DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")), + }) + if err != nil { + writeAccountError(w, r, err) + return + } + writeSuccess(w, r, accountResultPayload(result)) +} + +func (a *API) accountLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + if !a.validatePublicClient(w, r) { + return + } + var body accountLoginRequest + if !decodeBody(w, r, &body) { + return + } + result, err := a.accounts.Login(r.Context(), accountsvc.LoginInput{ + Phone: body.Phone, Password: body.Password, + DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")), + }) + if err != nil { + writeAccountError(w, r, err) + return + } + writeSuccess(w, r, accountResultPayload(result)) +} + +func (a *API) accountLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + if a.auth == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return + } + if err := a.auth.Logout(r); err != nil { + switch { + case errors.Is(err, auth.ErrAuthRequired): + writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil) + case errors.Is(err, auth.ErrForbidden): + writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } + return + } + writeSuccess(w, r, nil) +} + +func (a *API) validatePublicClient(w http.ResponseWriter, r *http.Request) bool { + if a.accounts == nil || a.auth == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return false + } + if err := a.auth.ValidateAPIKey(r); err != nil { + writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "valid API key required", nil) + return false + } + return true +} + +func accountResultPayload(result *accountsvc.Result) map[string]any { + return map[string]any{ + "member_id": result.Account.MemberID, + "member_no": result.Account.MemberNo, + "username": result.Account.Username, + "phone_mask": result.Account.MobileMask, + "invite_code": result.Account.InviteCode, + "token": result.Token, + "token_type": "Bearer", + "expires_at": result.ExpiresAt, + } +} + +func writeAccountError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, accountsvc.ErrUsernameInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "USERNAME_INVALID_FORMAT", "validation failed", map[string]any{"field": "username"}) + case errors.Is(err, accountsvc.ErrPhoneInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PHONE_INVALID_FORMAT", "validation failed", map[string]any{"field": "phone"}) + case errors.Is(err, accountsvc.ErrPasswordInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PASSWORD_INVALID_FORMAT", "validation failed", map[string]any{"field": "password"}) + case errors.Is(err, accountsvc.ErrReferralInvalid), errors.Is(err, store.ErrReferralInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "REFERRAL_CODE_INVALID", "validation failed", map[string]any{"field": "referral_code"}) + case errors.Is(err, accountsvc.ErrPrivacyNotAccepted): + writeError(w, r, http.StatusBadRequest, 30000, "PRIVACY_POLICY_NOT_ACCEPTED", "validation failed", map[string]any{"field": "privacy_accepted"}) + case errors.Is(err, store.ErrAccountExists): + writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_ALREADY_REGISTERED", "account already registered", nil) + case errors.Is(err, store.ErrInvalidCredentials): + writeError(w, r, http.StatusUnauthorized, 30043, "INVALID_CREDENTIALS", "invalid phone or password", nil) + case errors.Is(err, accountsvc.ErrRateLimited): + writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many login attempts", nil) + case errors.Is(err, accountsvc.ErrUnavailable): + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go deleted file mode 100644 index 1995e45..0000000 --- a/internal/httpapi/admin.go +++ /dev/null @@ -1,392 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - var body struct { - TenantID int64 `json:"tenant_id"` - TenantCode string `json:"tenant_code"` - } - if !decodeJSON(w, r, &body) { - return - } - if body.TenantID <= 0 { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"}) - return - } - st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode)) - if err != nil { - writeStoreError(w, r, err) - return - } - result, err := st.InitializeTenantDefaults(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{ - "tenant_id": body.TenantID, - "workbench_apps": result.WorkbenchApps, - "attendance_rules": result.AttendanceRules, - "attendance_shifts": result.AttendanceShifts, - "mailbox_configs": result.MailboxConfigs, - "feedback_configs": result.FeedbackConfigs, - }) -} - -func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - switch r.Method { - case http.MethodGet: - page := queryInt(r, "page", 1) - perPage := queryInt(r, "per_page", 20) - var enabled *int - if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" { - if value, err := strconv.Atoi(raw); err == nil { - enabled = &value - } - } - items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage}) - case http.MethodPost: - var body adminWorkbenchModuleRequest - if !decodeJSON(w, r, &body) { - return - } - item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{ - ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route, - SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission, - }) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, adminWorkbenchModulePayload(*item)) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - var body struct { - ID int64 `json:"id"` - Code string `json:"code"` - } - if !decodeJSON(w, r, &body) { - return - } - if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"ok": true}) -} - -func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - var body struct { - Items []struct { - Code string `json:"code"` - SortOrder int `json:"sort_order"` - } `json:"items"` - } - if !decodeJSON(w, r, &body) { - return - } - items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items)) - for _, item := range body.Items { - items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder}) - } - if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"ok": true}) -} - -func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - page := queryInt(r, "page", 1) - perPage := queryInt(r, "per_page", 20) - items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage, - r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage}) -} - -func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - page := queryInt(r, "page", 1) - perPage := queryInt(r, "per_page", 20) - userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64) - items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage, - r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")), - r.URL.Query().Get("employment_state"), userID) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage}) -} - -func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - switch r.Method { - case http.MethodGet: - cfg, err := st.AdminMailboxConfig(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, adminMailboxConfigPayload(*cfg)) - case http.MethodPost: - var body adminMailboxConfigRequest - if !decodeJSON(w, r, &body) { - return - } - if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{ - Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields, - MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent, - }); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"ok": true}) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) { - st, ok := a.internalAdminStore(w, r) - if !ok { - return - } - switch r.Method { - case http.MethodGet: - cfg, err := st.AdminFeedbackConfig(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, adminFeedbackConfigPayload(*cfg)) - case http.MethodPost: - var body adminFeedbackConfigRequest - if !decodeJSON(w, r, &body) { - return - } - if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{ - Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, - AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice, - }); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"ok": true}) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) { - tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64) - tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default") - st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode) - if err != nil { - writeStoreError(w, r, err) - return nil, false - } - return st, true -} - -type adminWorkbenchModuleRequest struct { - ID int64 `json:"id"` - Code string `json:"code"` - Name string `json:"name"` - IconFileID string `json:"icon_file_id"` - Route string `json:"route"` - SortOrder int `json:"sort_order"` - UnreadKey string `json:"unread_key"` - Enabled int `json:"enabled"` - Permission string `json:"permission"` -} - -type adminMailboxConfigRequest struct { - Enabled int `json:"enabled"` - AllowAnonymous int `json:"allow_anonymous"` - RequiredFields string `json:"required_fields"` - MaxLength int `json:"max_length"` - AttachmentPolicy string `json:"attachment_policy"` - NoticeTitle string `json:"notice_title"` - NoticeContent string `json:"notice_content"` -} - -type adminFeedbackConfigRequest struct { - Categories string `json:"categories"` - MaxLength int `json:"max_length"` - AttachmentPolicy string `json:"attachment_policy"` - AllowContact int `json:"allow_contact"` - DiagnosticsNotice string `json:"diagnostics_notice"` -} - -func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any { - res := make([]map[string]any, 0, len(items)) - for _, item := range items { - res = append(res, adminWorkbenchModulePayload(item)) - } - return res -} - -func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any { - return map[string]any{ - "id": item.ID, - "code": item.Code, - "name": item.Name, - "iconFileId": item.IconFileID, - "route": item.Route, - "sortOrder": item.SortOrder, - "unreadKey": item.UnreadKey, - "enabled": item.Enabled, - "permission": item.Permission, - "createdAt": formatAdminTime(item.CreatedAt), - "updatedAt": formatAdminTime(item.UpdatedAt), - } -} - -func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any { - res := make([]map[string]any, 0, len(items)) - for _, item := range items { - res = append(res, map[string]any{ - "department_id": item.DepartmentID, - "name": item.Name, - "parent_id": item.ParentID, - "child_count": item.ChildCount, - "member_count": item.MemberCount, - "version": item.Version, - }) - } - return res -} - -func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any { - res := make([]map[string]any, 0, len(items)) - for _, item := range items { - res = append(res, map[string]any{ - "user_id": item.UserID, - "department_id": item.DepartmentID, - "display_name": item.DisplayName, - "position": item.Position, - "mobile": item.Mobile, - "email": item.Email, - "avatar_file_id": item.AvatarFileID, - "status": item.Status, - "contactable": item.Contactable, - "version": item.Version, - }) - } - return res -} - -func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any { - return map[string]any{ - "id": cfg.ID, - "enabled": cfg.Enabled, - "allowAnonymous": cfg.AllowAnonymous, - "requiredFields": cfg.RequiredFields, - "maxLength": cfg.MaxLength, - "attachmentPolicy": cfg.AttachmentPolicy, - "noticeTitle": cfg.NoticeTitle, - "noticeContent": cfg.NoticeContent, - "noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt), - "updatedAt": formatAdminTime(cfg.UpdatedAt), - } -} - -func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any { - return map[string]any{ - "id": cfg.ID, - "categories": cfg.Categories, - "maxLength": cfg.MaxLength, - "attachmentPolicy": cfg.AttachmentPolicy, - "allowContact": cfg.AllowContact, - "diagnosticsNotice": cfg.DiagnosticsNotice, - "updatedAt": formatAdminTime(cfg.UpdatedAt), - } -} - -func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool { - if err := json.NewDecoder(r.Body).Decode(out); err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) - return false - } - return true -} - -func queryInt(r *http.Request, key string, fallback int) int { - value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key))) - if err != nil || value <= 0 { - return fallback - } - return value -} - -func formatAdminTime(t time.Time) string { - if t.IsZero() { - return "" - } - return t.UTC().Format(time.RFC3339Nano) -} diff --git a/internal/httpapi/ai.go b/internal/httpapi/ai.go deleted file mode 100644 index 97aa385..0000000 --- a/internal/httpapi/ai.go +++ /dev/null @@ -1,1183 +0,0 @@ -package httpapi - -import ( - "archive/zip" - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "encoding/xml" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/ai" - "git.118im.com/im_team/im_workbench/internal/auth" - "git.118im.com/im_team/im_workbench/internal/store" - "git.118im.com/im_team/im_workbench/internal/tableparse" -) - -const ( - reasonAINotConfigured = "AI_PROVIDER_NOT_CONFIGURED" - reasonAIProviderFailed = "AI_PROVIDER_FAILED" - reasonAITimeout = "AI_TIMEOUT" - reasonAITaskFailed = "AI_TASK_FAILED" - reasonAIFeatureClosed = "AI_FEATURE_DISABLED" -) - -func (a *API) aiCapabilities(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - caps, err := st.AICapabilities(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{ - "enabled": anyCapabilityEnabled(caps), - "features": aiCapabilityPayloads(caps), - }) -} - -func (a *API) aiTextPolish(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiPolishRequest - if !decodeBody(w, r, &body) { - return - } - feature := aiPolishFeature(body.Scene) - cap, ok := a.requireAICapability(w, r, st, feature) - if !ok { - return - } - source := strings.TrimSpace(body.SourceText) - maxInput := aiMaxInputChars(cap, 1000) - if source == "" || len([]rune(source)) > maxInput { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) - return - } - maxOutput := body.MaxOutputChars - if maxOutput <= 0 { - maxOutput = 300 - } - result, err := a.ai.Polish(r.Context(), source, strings.TrimSpace(body.Scene), strings.TrimSpace(body.Tone), maxOutput) - if err != nil { - a.writeAIError(w, r, err) - _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ - TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, - Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source), - }) - return - } - _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ - TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, - Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, - PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, - LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.Text), - }) - writeSuccess(w, r, map[string]any{ - "source_text": source, - "polished_text": result.Text, - "usage": map[string]any{"remaining_today": 0}, - }) -} - -func (a *API) aiDocumentGenerate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiDocumentGenerateRequest - if !decodeBody(w, r, &body) { - return - } - cap, ok := a.requireAICapability(w, r, st, "smart_document") - if !ok { - return - } - source := strings.TrimSpace(body.SourceText) - maxInput := aiMaxInputChars(cap, 1000) - if source == "" || len([]rune(source)) > maxInput { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) - return - } - taskID := "aitask_" + randomToken(12) - snapshot, err := aiTaskSnapshot(aiDocumentTaskSnapshot{ - SourceText: source, - TitleHint: strings.TrimSpace(body.TitleHint), - DocumentType: strings.TrimSpace(body.DocumentType), - Language: strings.TrimSpace(body.Language), - }) - if err != nil { - writeStoreError(w, r, err) - return - } - if err := st.CreateAITask(r.Context(), store.AITask{ - TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_document", - Status: "queued", Progress: 0, SourceType: "text", InputHash: hashText(source), - InputSnapshot: snapshot, - PromptVersion: "smart_document_v1", Model: "", - }, nil); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{ - "task_id": taskID, - "status": "queued", - "polling_interval_ms": 1500, - "expires_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339Nano), - }) -} - -func (a *API) aiTaskResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - taskID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/tasks/") - if !ok { - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - return - } - if tail == "cancel" && r.Method == http.MethodPost { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - if err := st.CancelAITask(r.Context(), p.UserID, taskID); err != nil { - writeStoreError(w, r, err) - return - } - feature := "" - if task, err := st.AITask(r.Context(), p.UserID, taskID); err == nil { - feature = task.Feature - } - a.publishAITaskEvent(r.Context(), p.TenantID, p.TenantCode, p.UserID, taskID, "ai", "AI 任务已取消", "", map[string]any{ - "task_id": taskID, "feature": feature, "status": "canceled", "progress": 100, "result_type": "none", - }) - writeSuccess(w, r, map[string]any{"task_id": taskID, "status": "canceled", "canceled_at": time.Now().UTC().Format(time.RFC3339Nano)}) - return - } - if tail != "" { - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - return - } - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - task, err := st.AITask(r.Context(), p.UserID, taskID) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, aiTaskPayload(task)) -} - -func (a *API) aiDocuments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - switch r.Method { - case http.MethodGet: - items, next, err := st.AIDocuments(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, pagePayload(aiRecordPayloads(items), next)) - case http.MethodPost: - a.aiDocumentSave(w, r, p, st) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) aiDocumentSave(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiDocumentSaveRequest - if !decodeBody(w, r, &body) { - return - } - title := strings.TrimSpace(body.Title) - content := strings.TrimSpace(body.ContentMarkdown) - if title == "" || content == "" || strings.TrimSpace(body.TaskID) == "" { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) - return - } - task, err := st.AITask(r.Context(), p.UserID, strings.TrimSpace(body.TaskID)) - if err != nil { - writeStoreError(w, r, err) - return - } - if task.Status != "succeeded" { - writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", map[string]any{"status": task.Status}) - return - } - recordID := "airec_doc_" + randomToken(10) - sectionsJSON := "[]" - if len(body.Sections) > 0 { - if data, err := json.Marshal(body.Sections); err == nil { - sectionsJSON = string(data) - } - } else if task.ResultJSON.Valid { - var result map[string]any - if json.Unmarshal([]byte(task.ResultJSON.String), &result) == nil { - if sections, ok := result["sections"]; ok { - if data, err := json.Marshal(sections); err == nil { - sectionsJSON = string(data) - } - } - } - } - if err := st.SaveAIDocument(r.Context(), p.TenantCode, p.UserID, store.AIRecord{ - RecordID: recordID, RecordType: "smart_document", Title: title, Summary: excerpt(firstNonEmpty(body.ContentPlain, content), 120), - Status: "succeeded", TaskID: task.TaskID, - }, store.AIDocument{ - RecordID: recordID, DocumentType: firstNonEmpty(body.DocumentType, "general"), Title: title, ContentMarkdown: content, - ContentPlain: strings.TrimSpace(body.ContentPlain), SectionsJSON: sectionsJSON, SourceExcerpt: "", PromptVersion: task.PromptVersion, Model: task.Model, - }); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{ - "record_id": recordID, - "title": title, - "created_at": time.Now().UTC().Format(time.RFC3339Nano), - "updated_at": time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func (a *API) aiDocumentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/documents/") - if !ok || tail != "" { - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - return - } - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - doc, err := st.AIDocument(r.Context(), p.UserID, recordID) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, aiDocumentPayload(doc)) -} - -func (a *API) aiRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - items, next, err := st.AIRecords(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("type")), strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, pagePayload(aiGenericRecordPayloads(items), next)) -} - -func (a *API) aiRecordResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/records/") - if !ok || tail != "" { - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - return - } - if r.Method != http.MethodDelete { - methodNotAllowed(w, r) - return - } - if err := st.DeleteAIRecord(r.Context(), p.UserID, recordID); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"record_id": recordID, "deleted": true}) -} - -func (a *API) aiTableAnalyses(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - switch r.Method { - case http.MethodGet: - items, next, err := st.AITableAnalyses(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, pagePayload(aiTableRecordPayloads(items), next)) - case http.MethodPost: - a.aiTableAnalysisCreate(w, r, p, st) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) aiTableAnalysisCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiTableAnalysisRequest - if !decodeBody(w, r, &body) { - return - } - cap, ok := a.requireAICapability(w, r, st, "smart_table") - if !ok { - return - } - fileID := strings.TrimSpace(body.FileID) - topic := strings.TrimSpace(body.Topic) - fileType := strings.ToLower(strings.TrimSpace(body.FileType)) - if fileID == "" || topic == "" || !allowedTableFileType(fileType) { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) - return - } - maxInput := aiMaxInputChars(cap, 100) - if len([]rune(topic)) > maxInput { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "topic", "max_length": maxInput}) - return - } - fileMeta := st.AIFileMetadata(r.Context(), fileID) - fileSize := body.FileSize - if fileSize <= 0 && fileMeta.FileSize > 0 { - fileSize = fileMeta.FileSize - } - if cap.MaxFileSizeMB > 0 && fileSize > int64(cap.MaxFileSizeMB)*1024*1024 { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "file_size", "max_file_size_mb": cap.MaxFileSizeMB}) - return - } - taskID := "aitask_tbl_" + randomToken(12) - recordID := "airec_tbl_" + randomToken(10) - snapshot, err := aiTaskSnapshot(aiTableTaskSnapshot{ - FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, - Topic: topic, SheetName: strings.TrimSpace(body.SheetName), Language: strings.TrimSpace(body.Language), RecordID: recordID, - }) - if err != nil { - writeStoreError(w, r, err) - return - } - if err := st.CreateAITask(r.Context(), store.AITask{ - TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "smart_table", - Status: "queued", Progress: 0, SourceType: "file", SourceRefID: fileID, InputHash: hashText(fileID + ":" + topic), - InputSnapshot: snapshot, PromptVersion: "smart_table_v1", - }, nil); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"task_id": taskID, "record_id": recordID, "status": "queued", "polling_interval_ms": 1500}) -} - -func (a *API) aiTableAnalysisResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/table-analyses/") - if !ok { - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - return - } - if tail == "export" && r.Method == http.MethodPost { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiTableExportRequest - if !decodeBody(w, r, &body) { - return - } - format := strings.ToLower(strings.TrimSpace(body.Format)) - if format == "" { - format = "docx" - } - if format != "docx" { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) - return - } - item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) - if err != nil { - writeStoreError(w, r, err) - return - } - file, err := a.uploadAITableAnalysisExport(r.Context(), r, st, p, item) - if err != nil { - writeError(w, r, http.StatusServiceUnavailable, 30053, reasonAIProviderFailed, "ai export storage failed", nil) - return - } - writeSuccess(w, r, map[string]any{ - "file_id": file.FileID, - "file_name": file.FileName, - "download_url": file.DownloadURL, - "expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano), - }) - return - } - if tail == "export/download" && r.Method == http.MethodGet { - format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) - if format == "" { - format = "docx" - } - if format != "docx" { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "format", "supported": []string{"docx"}}) - return - } - item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) - if err != nil { - writeStoreError(w, r, err) - return - } - data, err := aiTableAnalysisDocx(item) - if err != nil { - writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) - return - } - fileName := aiTableExportFileName(item) - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") - w.Header().Set("Content-Disposition", contentDispositionAttachment(fileName)) - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(data) - return - } - if tail != "" || r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, aiTableAnalysisPayload(item)) -} - -func (a *API) aiMeetingSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { - switch r.Method { - case http.MethodGet: - if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil { - writeStoreError(w, r, err) - return - } - summary, err := st.AIMeetingSummary(r.Context(), p.UserID, meetingID) - if errors.Is(err, store.ErrNotFound) { - writeSuccess(w, r, map[string]any{"meeting_id": meetingID, "status": "empty"}) - return - } - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, aiMeetingSummaryPayload(summary)) - case http.MethodPost: - a.aiMeetingSummaryCreate(w, r, p, st, meetingID) - case http.MethodPut: - a.aiMeetingSummaryUpdate(w, r, p, st, meetingID) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) aiMeetingSummaryCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiMeetingSummaryRequest - if !decodeBody(w, r, &body) { - return - } - if _, ok := a.requireAICapability(w, r, st, "meeting_summary"); !ok { - return - } - if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil { - writeStoreError(w, r, err) - return - } - files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID) - if err != nil { - writeStoreError(w, r, err) - return - } - _, fileIDs := meetingSummaryFiles(files, body.FileIDs) - taskID := "aitask_mtg_" + randomToken(12) - summaryID := "aisum_" + randomToken(10) - snapshot, err := aiTaskSnapshot(aiMeetingSummaryTaskSnapshot{ - MeetingID: meetingID, SummaryID: summaryID, FileIDs: fileIDs, - SummaryType: strings.TrimSpace(body.SummaryType), ForceRegenerate: body.ForceRegenerate, - }) - if err != nil { - writeStoreError(w, r, err) - return - } - if err := st.CreateAITask(r.Context(), store.AITask{ - TaskID: taskID, TenantCode: p.TenantCode, UserID: strconv.FormatInt(p.UserID, 10), Feature: "meeting_summary", - Status: "queued", Progress: 0, SourceType: "meeting", SourceRefID: meetingID, InputHash: hashText(meetingID + strings.Join(fileIDs, ",")), - InputSnapshot: snapshot, PromptVersion: "meeting_summary_v1", - }, nil); err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, map[string]any{"task_id": taskID, "meeting_id": meetingID, "summary_id": summaryID, "status": "queued", "polling_interval_ms": 1500}) -} - -func (a *API) aiMeetingSummaryUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { - if _, ok := requireIdempotencyKey(w, r); !ok { - return - } - var body aiMeetingSummaryUpdateRequest - if !decodeBody(w, r, &body) { - return - } - summaryID := strings.TrimSpace(body.SummaryID) - if summaryID == "" { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "summary_id"}) - return - } - summary, err := st.UpdateAIMeetingSummary(r.Context(), p.UserID, meetingID, summaryID, mustJSON(body.Conclusions, "[]"), mustJSON(body.Decisions, "[]"), mustJSON(body.ActionItems, "[]")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, aiMeetingSummaryPayload(summary)) -} - -func (a *API) workbenchApps(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) - if err != nil { - writeStoreError(w, r, err) - return - } - apps := workbenchAppPayloads(modules) - caps, err := st.AICapabilities(r.Context()) - if err == nil { - apps = append(apps, aiAppPayloads(caps)...) - } - writeSuccess(w, r, map[string]any{"common_apps": apps, "all_apps": apps}) -} - -func (a *API) workbenchAppsSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - 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 - } - modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) - if err != nil { - writeStoreError(w, r, err) - return - } - apps := workbenchAppPayloads(modules) - if caps, err := st.AICapabilities(r.Context()); err == nil { - apps = append(apps, aiAppPayloads(caps)...) - } - items := make([]map[string]any, 0) - for _, app := range apps { - name, _ := app["name"].(string) - if strings.Contains(strings.ToLower(name), strings.ToLower(query)) { - app["matched_text"] = name - items = append(items, app) - } - } - writeSuccess(w, r, map[string]any{"items": items, "has_more": false}) -} - -func (a *API) writeAIError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, ai.ErrNotConfigured): - writeError(w, r, http.StatusServiceUnavailable, 30050, reasonAINotConfigured, "ai provider not configured", nil) - case errors.Is(err, ai.ErrTimeout): - writeError(w, r, http.StatusGatewayTimeout, 30051, reasonAITimeout, "ai timeout", nil) - case errors.Is(err, ai.ErrBadOutput): - writeError(w, r, http.StatusBadGateway, 30052, reasonAITaskFailed, "ai task failed", nil) - default: - writeError(w, r, http.StatusBadGateway, 30053, reasonAIProviderFailed, "ai provider failed", nil) - } -} - -func aiErrorReason(err error) string { - switch { - case errors.Is(err, ai.ErrNotConfigured): - return reasonAINotConfigured - case errors.Is(err, ai.ErrTimeout): - return reasonAITimeout - case errors.Is(err, ai.ErrBadOutput): - return reasonAITaskFailed - default: - return reasonAIProviderFailed - } -} - -func (a *API) requireAICapability(w http.ResponseWriter, r *http.Request, st *store.Store, feature string) (store.AICapability, bool) { - caps, err := st.AICapabilities(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return store.AICapability{}, false - } - for _, cap := range caps { - if cap.Feature != feature { - continue - } - if !cap.Enabled { - writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) - return store.AICapability{}, false - } - return cap, true - } - writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) - return store.AICapability{}, false -} - -func aiPolishFeature(scene string) string { - switch strings.TrimSpace(scene) { - case "daily_report": - return "daily_report_polish" - case "monthly_report": - return "monthly_report_polish" - default: - return "text_polish" - } -} - -func aiMaxInputChars(cap store.AICapability, fallback int) int { - if cap.MaxInputChars > 0 { - return cap.MaxInputChars - } - return fallback -} - -type aiPolishRequest struct { - SourceText string `json:"source_text"` - Scene string `json:"scene"` - Tone string `json:"tone"` - MaxOutputChars int `json:"max_output_chars"` -} - -type aiTableExportRequest struct { - Format string `json:"format"` -} - -type aiDocumentGenerateRequest struct { - SourceText string `json:"source_text"` - TitleHint string `json:"title_hint"` - DocumentType string `json:"document_type"` - Language string `json:"language"` -} - -type aiDocumentSaveRequest struct { - TaskID string `json:"task_id"` - Title string `json:"title"` - DocumentType string `json:"document_type"` - ContentMarkdown string `json:"content_markdown"` - ContentPlain string `json:"content_plain"` - Sections []ai.DocumentSection `json:"sections"` -} - -type aiTableAnalysisRequest struct { - FileID string `json:"file_id"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - FileSize int64 `json:"file_size"` - Topic string `json:"topic"` - SheetName string `json:"sheet_name"` - Language string `json:"language"` -} - -type aiMeetingSummaryRequest struct { - FileIDs []string `json:"file_ids"` - SummaryType string `json:"summary_type"` - ForceRegenerate bool `json:"force_regenerate"` -} - -type aiMeetingSummaryUpdateRequest struct { - SummaryID string `json:"summary_id"` - Conclusions []string `json:"conclusions"` - Decisions []string `json:"decisions"` - ActionItems []ai.MeetingActionItem `json:"action_items"` -} - -func aiCapabilityPayloads(caps []store.AICapability) []map[string]any { - items := make([]map[string]any, 0, len(caps)) - for _, cap := range caps { - cfg := jsonObject(cap.ConfigJSON) - title, _ := cfg["entry_title"].(string) - items = append(items, map[string]any{ - "feature": cap.Feature, - "enabled": cap.Enabled, - "entry_title": firstNonEmpty(title, cap.Feature), - "daily_limit": cap.DailyLimit, - "remaining_today": cap.DailyLimit, - "max_input_chars": cap.MaxInputChars, - "max_file_size_mb": cap.MaxFileSizeMB, - "model": cap.Model, - }) - } - return items -} - -func anyCapabilityEnabled(caps []store.AICapability) bool { - for _, cap := range caps { - if cap.Enabled { - return true - } - } - return false -} - -func aiTaskPayload(task *store.AITask) map[string]any { - var result any - if task.ResultJSON.Valid && strings.TrimSpace(task.ResultJSON.String) != "" { - var decoded any - if json.Unmarshal([]byte(task.ResultJSON.String), &decoded) == nil { - result = decoded - } - } - var errPayload any - if task.ErrorReason != "" { - errPayload = map[string]any{"reason": task.ErrorReason, "message": task.ErrorMessage} - } - return map[string]any{ - "task_id": task.TaskID, - "feature": task.Feature, - "status": task.Status, - "progress": task.Progress, - "steps": []any{}, - "result": result, - "error": errPayload, - } -} - -func aiRecordPayloads(records []store.AIRecord) []map[string]any { - items := make([]map[string]any, 0, len(records)) - for _, record := range records { - items = append(items, map[string]any{ - "record_id": record.RecordID, - "title": record.Title, - "summary": record.Summary, - "document_type": "general", - "status": record.Status, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), - }) - } - return items -} - -func aiTableRecordPayloads(records []store.AIRecord) []map[string]any { - items := make([]map[string]any, 0, len(records)) - for _, record := range records { - fileType := strings.ToLower(strings.TrimSpace(record.FileType)) - items = append(items, map[string]any{ - "record_id": record.RecordID, - "title": record.Title, - "summary": record.Summary, - "format": fileType, - "file_type": fileType, - "file_name": record.FileName, - "file_size": record.FileSize, - "status": record.Status, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), - }) - } - return items -} - -func aiGenericRecordPayloads(records []store.AIRecord) []map[string]any { - items := make([]map[string]any, 0, len(records)) - for _, record := range records { - items = append(items, map[string]any{ - "record_id": record.RecordID, - "type": record.RecordType, - "title": record.Title, - "summary": record.Summary, - "status": record.Status, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), - "route": aiRecordRoute(record), - }) - } - return items -} - -func aiRecordRoute(record store.AIRecord) string { - switch record.RecordType { - case "smart_document": - return "/workbench/ai/document/" + record.RecordID - case "smart_table": - return "/workbench/ai/table/" + record.RecordID - default: - return "" - } -} - -func aiDocumentPayload(doc *store.AIDocument) map[string]any { - var sections any = []any{} - if strings.TrimSpace(doc.SectionsJSON) != "" { - var decoded any - if json.Unmarshal([]byte(doc.SectionsJSON), &decoded) == nil { - sections = decoded - } - } - return map[string]any{ - "record_id": doc.RecordID, - "title": doc.Title, - "document_type": doc.DocumentType, - "content_markdown": doc.ContentMarkdown, - "content_plain": doc.ContentPlain, - "sections": sections, - "source_excerpt": doc.SourceExcerpt, - "created_at": doc.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": doc.UpdatedAt.UTC().Format(time.RFC3339Nano), - } -} - -func aiTableAnalysisPayload(item *store.AITableAnalysis) map[string]any { - return map[string]any{ - "record_id": item.RecordID, - "title": item.Topic, - "file": map[string]any{ - "file_id": item.FileID, "file_name": item.FileName, "file_type": item.FileType, "file_size": item.FileSize, - }, - "metrics": decodeJSONAny(item.MetricsJSON, []any{}), - "preview": decodeJSONAny(item.PreviewJSON, map[string]any{}), - "alerts": decodeJSONAny(item.AlertsJSON, []any{}), - "interpretation": item.Interpretation, - "export_options": []string{"docx"}, - "created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), - } -} - -func aiMeetingSummaryPayload(item *store.AIMeetingSummary) map[string]any { - payload := map[string]any{ - "meeting_id": item.MeetingID, - "summary_id": item.SummaryID, - "status": "succeeded", - "conclusions": decodeJSONAny(item.ConclusionsJSON, []any{}), - "decisions": decodeJSONAny(item.DecisionsJSON, []any{}), - "action_items": decodeJSONAny(item.ActionItemsJSON, []any{}), - "editable": true, - "version": item.Version, - "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if item.EditedAt != nil { - payload["edited_at"] = item.EditedAt.UTC().Format(time.RFC3339Nano) - payload["edited_by"] = item.EditedBy - } - return payload -} - -func meetingSummaryFiles(files []store.MeetingNoteFile, requested []string) ([]string, []string) { - filter := map[string]bool{} - for _, id := range requested { - id = strings.TrimSpace(id) - if id != "" { - filter[id] = true - } - } - names := make([]string, 0, len(files)) - ids := make([]string, 0, len(files)) - for _, file := range files { - if len(filter) > 0 && !filter[file.FileID] { - continue - } - names = append(names, file.FileName) - ids = append(ids, file.FileID) - } - return names, ids -} - -func meetingSummaryParticipants(meeting store.Meeting) []string { - items := make([]string, 0, len(meeting.Participants)+1) - if meeting.Organizer.DisplayName != "" { - items = append(items, meeting.Organizer.DisplayName) - } - for _, participant := range meeting.Participants { - name := participant.User.DisplayName - if name == "" { - name = formatUserID(participant.UserID) - } - items = append(items, name) - } - return items -} - -func resolveTableFilePath(fileID, location string) string { - candidates := []string{strings.TrimSpace(location), strings.TrimSpace(fileID)} - for _, candidate := range candidates { - if candidate == "" || strings.HasPrefix(candidate, "http://") || strings.HasPrefix(candidate, "https://") { - continue - } - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - } - return "" -} - -func (a *API) tableProfileForAI(r *http.Request, fileID string, meta store.AIFileMetadata, fileType string) ai.TableProfile { - if path := resolveTableFilePath(fileID, meta.Location); path != "" { - return tableProfileFromPath(path, fileType) - } - if path, cleanup, err := downloadWorkbenchFileToTemp(r, fileID, fileType); err == nil { - defer cleanup() - return tableProfileFromPath(path, fileType) - } - if strings.TrimSpace(meta.Location) == "" { - return ai.TableProfile{Parsed: false, Message: "source file metadata is not available"} - } - path, cleanup, err := a.downloadOSSObjectToTemp(r.Context(), meta.Location, fileType) - if err != nil { - return ai.TableProfile{Parsed: false, Message: "source file is not available: " + err.Error()} - } - defer cleanup() - return tableProfileFromPath(path, fileType) -} - -func tableProfileFromPath(path, fileType string) ai.TableProfile { - if strings.TrimSpace(path) == "" { - return ai.TableProfile{Parsed: false, Message: "source file is not available locally"} - } - profile, err := tableparse.Parse(path, fileType) - if err != nil { - return ai.TableProfile{Parsed: false, Message: err.Error()} - } - return ai.TableProfile{ - Columns: profile.Columns, - SampleRows: profile.SampleRows, - RowCount: profile.RowCount, - Stats: profile.Stats, - Outliers: profile.Outliers, - Parsed: profile.Parsed, - Message: profile.Message, - } -} - -func allowedTableFileType(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "xlsx", "xls", "csv": - return true - default: - return false - } -} - -func aiTableAnalysisDocx(item *store.AITableAnalysis) ([]byte, error) { - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - files := map[string]string{ - "[Content_Types].xml": ` - - - - - - -`, - "_rels/.rels": ` - - - - -`, - "docProps/app.xml": ` -IM Workbench`, - "docProps/core.xml": aiDocxCoreXML(item), - "word/document.xml": aiTableAnalysisDocumentXML(item), - } - for name, content := range files { - w, err := zw.Create(name) - if err != nil { - return nil, err - } - if _, err := w.Write([]byte(content)); err != nil { - return nil, err - } - } - if err := zw.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func aiTableAnalysisDocumentXML(item *store.AITableAnalysis) string { - var b strings.Builder - b.WriteString(``) - b.WriteString(``) - docxParagraph(&b, firstNonEmpty(item.Topic, "智能表格解读报告"), true) - docxParagraph(&b, "文件信息", true) - docxParagraph(&b, "文件名:"+item.FileName, false) - docxParagraph(&b, "文件类型:"+item.FileType, false) - docxParagraph(&b, "文件大小:"+strconv.FormatInt(item.FileSize, 10)+" bytes", false) - docxParagraph(&b, "核心指标", true) - for _, metric := range aiExportList(item.MetricsJSON) { - docxParagraph(&b, "- "+aiExportMetricText(metric), false) - } - docxParagraph(&b, "异常提醒", true) - for _, alert := range aiExportList(item.AlertsJSON) { - docxParagraph(&b, "- "+aiExportAlertText(alert), false) - } - docxParagraph(&b, "核心解读", true) - for _, line := range strings.Split(strings.TrimSpace(item.Interpretation), "\n") { - if strings.TrimSpace(line) != "" { - docxParagraph(&b, strings.TrimSpace(line), false) - } - } - b.WriteString(``) - b.WriteString(``) - return b.String() -} - -func docxParagraph(b *strings.Builder, text string, bold bool) { - b.WriteString(``) - if bold { - b.WriteString(``) - } - b.WriteString(``) - _ = xml.EscapeText(b, []byte(text)) - b.WriteString(``) -} - -func aiDocxCoreXML(item *store.AITableAnalysis) string { - created := item.CreatedAt.UTC().Format(time.RFC3339) - title := xmlEscape(firstNonEmpty(item.Topic, "智能表格解读报告")) - return ` - -` + title + `IM WorkbenchIM Workbench` + created + `` + time.Now().UTC().Format(time.RFC3339) + `` -} - -func aiExportList(raw string) []map[string]any { - var items []map[string]any - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - return items -} - -func aiExportMetricText(item map[string]any) string { - return strings.TrimSpace(fmt.Sprintf("%v:%v(%v,%v)", item["name"], item["value"], item["change_text"], item["trend"])) -} - -func aiExportAlertText(item map[string]any) string { - return strings.TrimSpace(fmt.Sprintf("[%v] %v:%v", item["level"], item["title"], item["description"])) -} - -func aiTableExportFileName(item *store.AITableAnalysis) string { - name := strings.TrimSpace(firstNonEmpty(item.Topic, item.RecordID, "智能表格解读报告")) - replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") - name = replacer.Replace(name) - if len([]rune(name)) > 80 { - name = string([]rune(name)[:80]) - } - return name + "-解读报告.docx" -} - -func absoluteURL(r *http.Request, path string) string { - scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) - if host == "" { - host = r.Host - } - return scheme + "://" + host + path -} - -func contentDispositionAttachment(fileName string) string { - escaped := url.QueryEscape(fileName) - return `attachment; filename="report.docx"; filename*=UTF-8''` + escaped -} - -func xmlEscape(value string) string { - var b strings.Builder - _ = xml.EscapeText(&b, []byte(value)) - return b.String() -} - -func mustJSON(value any, fallback string) string { - data, err := json.Marshal(value) - if err != nil || len(data) == 0 || string(data) == "null" { - return fallback - } - return string(data) -} - -func decodeJSONAny(raw string, fallback any) any { - if strings.TrimSpace(raw) == "" { - return fallback - } - var out any - if err := json.Unmarshal([]byte(raw), &out); err != nil { - return fallback - } - return out -} - -func workbenchAppPayloads(modules []store.WorkbenchModule) []map[string]any { - items := make([]map[string]any, 0, len(modules)) - for _, module := range modules { - items = append(items, map[string]any{ - "app_id": module.Code, - "name": module.Name, - "icon": module.IconFileID, - "route": module.Route, - "type": "builtin", - "enabled": module.Enabled, - }) - } - return items -} - -func aiAppPayloads(caps []store.AICapability) []map[string]any { - routes := map[string]string{ - "smart_document": "/workbench/ai/document", - "smart_table": "/workbench/ai/table", - } - items := make([]map[string]any, 0, len(caps)) - for _, cap := range caps { - route := routes[cap.Feature] - if route == "" { - continue - } - cfg := jsonObject(cap.ConfigJSON) - title, _ := cfg["entry_title"].(string) - items = append(items, map[string]any{ - "app_id": cap.Feature, - "name": firstNonEmpty(title, cap.Feature), - "icon": "ai", - "route": route, - "type": "ai", - "enabled": cap.Enabled, - }) - } - return items -} - -func hashText(text string) string { - sum := sha256.Sum256([]byte(text)) - return hex.EncodeToString(sum[:]) -} - -func excerpt(text string, maxRunes int) string { - runes := []rune(strings.TrimSpace(text)) - if len(runes) <= maxRunes { - return string(runes) - } - return string(runes[:maxRunes]) -} diff --git a/internal/httpapi/ai_export_oss.go b/internal/httpapi/ai_export_oss.go deleted file mode 100644 index 0e17298..0000000 --- a/internal/httpapi/ai_export_oss.go +++ /dev/null @@ -1,254 +0,0 @@ -package httpapi - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/base32" - "encoding/base64" - "encoding/binary" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/auth" - "git.118im.com/im_team/im_workbench/internal/managerconfig" - "git.118im.com/im_team/im_workbench/internal/store" - aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss" -) - -const aiTableExportDocxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - -type aiExportedFile struct { - FileID string - FileName string - DownloadURL string -} - -type ossUploadConfig struct { - Enabled bool - Endpoint string - Bucket string - AccessKeyID string - AccessKeySecret string - ServeURL string - CacheControl string -} - -func (a *API) uploadAITableAnalysisExport(ctx context.Context, r *http.Request, st *store.Store, p *auth.Principal, item *store.AITableAnalysis) (*aiExportedFile, error) { - cfg, err := a.ossUploadConfig(ctx) - if err != nil { - return nil, err - } - data, err := aiTableAnalysisDocx(item) - if err != nil { - return nil, err - } - fileID := newFileUploadID() - publicID, storageID := formatFileUploadID(fileID) - createdAt := time.Now().UTC() - objectKey := path.Join("tenants", strconv.FormatInt(p.TenantID, 10), createdAt.Format("2006"), createdAt.Format("01"), storageID) - client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) - if err != nil { - return nil, err - } - bucket, err := client.Bucket(cfg.Bucket) - if err != nil { - return nil, err - } - options := []aliyunoss.Option{aliyunoss.ContentType(aiTableExportDocxMIME)} - if cfg.CacheControl != "" { - options = append(options, aliyunoss.CacheControl(cfg.CacheControl)) - } - if err := bucket.PutObject(objectKey, bytes.NewReader(data), options...); err != nil { - return nil, err - } - etag := "" - if meta, err := bucket.GetObjectDetailedMeta(objectKey); err == nil { - etag = strings.Trim(meta.Get("ETag"), `"`) - } - fileName := aiTableExportFileName(item) - fileURLName := publicID + ".docx" - if err := st.SaveCompletedFileUpload(ctx, store.CompletedFileUpload{ - ID: fileID, UserID: p.UserID, MIMEType: aiTableExportDocxMIME, Size: int64(len(data)), - ETag: etag, Location: objectKey, CreatedAt: createdAt, - }); err != nil { - return nil, err - } - if err := st.UpdateAITableAnalysisExportFileID(ctx, p.UserID, item.RecordID, fileURLName); err != nil { - return nil, err - } - return &aiExportedFile{ - FileID: fileURLName, - FileName: fileName, - DownloadURL: publicFileDownloadURL(r, cfg.ServeURL, fileURLName), - }, nil -} - -func downloadWorkbenchFileToTemp(r *http.Request, fileID, fileType string) (string, func(), error) { - fileID = strings.TrimSpace(fileID) - if fileID == "" { - return "", func() {}, fmt.Errorf("empty file id") - } - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, absoluteURL(r, "/v0/file/s/"+url.PathEscape(fileID)), nil) - if err != nil { - return "", func() {}, err - } - copyHeader(req, r, "Authorization") - copyHeader(req, r, "X-MeetingIM-APIKey") - copyHeader(req, r, "X-Tenant-Code") - copyHeader(req, r, "X-IM-Schema") - copyHeader(req, r, "X-Request-ID") - if auth := strings.TrimSpace(r.Header.Get("X-MeetingIM-Auth")); auth != "" { - req.Header.Set("X-MeetingIM-Auth", auth) - } else if token := bearerTokenFromHeader(r.Header.Get("Authorization")); token != "" { - req.Header.Set("X-MeetingIM-Auth", "token "+token) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", func() {}, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", func() {}, fmt.Errorf("file service returned %d", resp.StatusCode) - } - return responseBodyToTemp(resp.Body, fileType) -} - -func (a *API) downloadOSSObjectToTemp(ctx context.Context, objectKey, fileType string) (string, func(), error) { - objectKey = strings.Trim(strings.TrimSpace(objectKey), "/") - if objectKey == "" { - return "", func() {}, fmt.Errorf("empty object key") - } - cfg, err := a.ossUploadConfig(ctx) - if err != nil { - return "", func() {}, err - } - client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) - if err != nil { - return "", func() {}, err - } - bucket, err := client.Bucket(cfg.Bucket) - if err != nil { - return "", func() {}, err - } - reader, err := bucket.GetObject(objectKey) - if err != nil { - return "", func() {}, err - } - defer reader.Close() - return responseBodyToTemp(reader, fileType) -} - -func responseBodyToTemp(reader io.Reader, fileType string) (string, func(), error) { - suffix := "." + strings.TrimLeft(strings.ToLower(strings.TrimSpace(fileType)), ".") - if suffix == "." { - suffix = ".table" - } - tmp, err := os.CreateTemp("", "im-workbench-ai-table-*"+suffix) - if err != nil { - return "", func() {}, err - } - tmpPath := tmp.Name() - cleanup := func() { _ = os.Remove(tmpPath) } - if _, err := io.Copy(tmp, reader); err != nil { - _ = tmp.Close() - cleanup() - return "", func() {}, err - } - if err := tmp.Close(); err != nil { - cleanup() - return "", func() {}, err - } - return tmpPath, cleanup, nil -} - -func copyHeader(dst *http.Request, src *http.Request, key string) { - if value := strings.TrimSpace(src.Header.Get(key)); value != "" { - dst.Header.Set(key, value) - } -} - -func bearerTokenFromHeader(value string) string { - token, ok := strings.CutPrefix(strings.TrimSpace(value), "Bearer ") - if !ok { - return "" - } - return strings.TrimSpace(token) -} - -func (a *API) ossUploadConfig(ctx context.Context) (ossUploadConfig, error) { - values, err := managerconfig.New(a.manager).ThirdPartyConfig(ctx, "im_third_party", - "oss_enabled", - "oss_endpoint", - "oss_bucket", - "oss_access_key_id", - "oss_access_key_secret", - "oss_serve_url", - "oss_cache_control", - ) - if err != nil { - return ossUploadConfig{}, err - } - cfg := ossUploadConfig{ - Enabled: parseConfigBool(values["oss_enabled"]), - Endpoint: strings.TrimSpace(values["oss_endpoint"]), - Bucket: strings.TrimSpace(values["oss_bucket"]), - AccessKeyID: strings.TrimSpace(values["oss_access_key_id"]), - AccessKeySecret: strings.TrimSpace(values["oss_access_key_secret"]), - ServeURL: strings.TrimSpace(values["oss_serve_url"]), - CacheControl: strings.TrimSpace(values["oss_cache_control"]), - } - if cfg.ServeURL == "" { - cfg.ServeURL = "/v0/file/s/" - } - if !cfg.Enabled || cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" { - return ossUploadConfig{}, fmt.Errorf("oss config incomplete") - } - return cfg, nil -} - -func parseConfigBool(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on", "enabled": - return true - default: - return false - } -} - -func newFileUploadID() int64 { - var random [8]byte - if _, err := rand.Read(random[:]); err != nil { - return time.Now().UnixNano() - } - suffix := int64(binary.LittleEndian.Uint64(random[:]) & ((1 << 20) - 1)) - return (time.Now().UnixMilli() << 20) | suffix -} - -func formatFileUploadID(id int64) (publicID string, storageID string) { - encrypted := encryptUID(id) - publicID = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encrypted) - storageID = strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(encrypted)) - return publicID, storageID -} - -func publicFileDownloadURL(r *http.Request, serveURL, fileName string) string { - serveURL = strings.TrimSpace(serveURL) - if strings.HasPrefix(serveURL, "http://") || strings.HasPrefix(serveURL, "https://") { - return strings.TrimRight(serveURL, "/") + "/" + fileName - } - if serveURL == "" { - serveURL = "/v0/file/s/" - } - if !strings.HasPrefix(serveURL, "/") { - serveURL = "/" + serveURL - } - return absoluteURL(r, strings.TrimRight(serveURL, "/")+"/"+fileName) -} diff --git a/internal/httpapi/ai_export_test.go b/internal/httpapi/ai_export_test.go deleted file mode 100644 index 3ae862d..0000000 --- a/internal/httpapi/ai_export_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package httpapi - -import ( - "archive/zip" - "bytes" - "io" - "strings" - "testing" - "time" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func TestAITableAnalysisDocx(t *testing.T) { - docx, err := aiTableAnalysisDocx(&store.AITableAnalysis{ - RecordID: "airec_tbl_test", - FileName: "sales.xlsx", - FileType: "xlsx", - FileSize: 1234, - Topic: "销售数据", - MetricsJSON: `[{"name":"销售额","value":"12万","change_text":"+10%","trend":"up"}]`, - AlertsJSON: `[{"level":"warning","title":"异常波动","description":"订单量下降"}]`, - Interpretation: "整体销售额增长。", - CreatedAt: time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatal(err) - } - zr, err := zip.NewReader(bytes.NewReader(docx), int64(len(docx))) - if err != nil { - t.Fatalf("docx is not a zip: %v", err) - } - var document string - for _, file := range zr.File { - if file.Name != "word/document.xml" { - continue - } - rc, err := file.Open() - if err != nil { - t.Fatal(err) - } - data, err := io.ReadAll(rc) - _ = rc.Close() - if err != nil { - t.Fatal(err) - } - document = string(data) - break - } - for _, want := range []string{"销售数据", "销售额", "异常波动", "整体销售额增长"} { - if !strings.Contains(document, want) { - t.Fatalf("document.xml missing %q: %s", want, document) - } - } -} diff --git a/internal/httpapi/ai_worker.go b/internal/httpapi/ai_worker.go deleted file mode 100644 index b48ef20..0000000 --- a/internal/httpapi/ai_worker.go +++ /dev/null @@ -1,366 +0,0 @@ -package httpapi - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "log" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/ai" - "git.118im.com/im_team/im_workbench/internal/events" - "git.118im.com/im_team/im_workbench/internal/store" -) - -const ( - aiWorkerCount = 2 - aiWorkerPollInterval = time.Second - aiTaskRunTimeout = 3 * time.Minute -) - -type aiDocumentTaskSnapshot struct { - SourceText string `json:"source_text"` - TitleHint string `json:"title_hint"` - DocumentType string `json:"document_type"` - Language string `json:"language"` -} - -type aiTableTaskSnapshot struct { - FileID string `json:"file_id"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - FileSize int64 `json:"file_size"` - Topic string `json:"topic"` - SheetName string `json:"sheet_name"` - Language string `json:"language"` - RecordID string `json:"record_id"` -} - -type aiMeetingSummaryTaskSnapshot struct { - MeetingID string `json:"meeting_id"` - SummaryID string `json:"summary_id"` - FileIDs []string `json:"file_ids"` - SummaryType string `json:"summary_type"` - ForceRegenerate bool `json:"force_regenerate"` -} - -func (a *API) StartAIWorkers(ctx context.Context) { - if a == nil || a.store == nil || a.ai == nil { - return - } - for i := 0; i < aiWorkerCount; i++ { - go a.aiWorkerLoop(ctx, i+1) - } -} - -func (a *API) aiWorkerLoop(ctx context.Context, workerID int) { - ticker := time.NewTicker(aiWorkerPollInterval) - defer ticker.Stop() - for { - task, err := a.store.ClaimQueuedAITask(ctx) - if errors.Is(err, store.ErrNotFound) { - select { - case <-ctx.Done(): - return - case <-ticker.C: - continue - } - } - if err != nil { - log.Printf("ai worker claim failed: worker=%d err=%v", workerID, err) - select { - case <-ctx.Done(): - return - case <-ticker.C: - continue - } - } - a.runAITask(ctx, task) - } -} - -func (a *API) runAITask(parent context.Context, task *store.AITask) { - ctx, cancel := context.WithTimeout(parent, aiTaskRunTimeout) - defer cancel() - st, err := a.store.ForTenant(ctx, task.TenantID, task.TenantCode) - if err != nil { - log.Printf("ai task tenant store failed: task_id=%s tenant_id=%d err=%v", task.TaskID, task.TenantID, err) - return - } - switch task.Feature { - case "smart_document": - a.runAIDocumentTask(ctx, st, task) - case "smart_table": - a.runAITableTask(ctx, st, task) - case "meeting_summary": - a.runAIMeetingSummaryTask(ctx, st, task) - default: - a.finishAITaskFailure(ctx, st, task, "AI_TASK_UNSUPPORTED", "unsupported ai task feature") - } -} - -func (a *API) runAIDocumentTask(ctx context.Context, st *store.Store, task *store.AITask) { - var input aiDocumentTaskSnapshot - if !decodeTaskSnapshot(task, &input) { - a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input") - return - } - result, err := a.ai.GenerateDocument(ctx, strings.TrimSpace(input.SourceText), strings.TrimSpace(input.TitleHint), strings.TrimSpace(input.DocumentType), strings.TrimSpace(input.Language)) - if err != nil { - a.finishAITaskAIError(ctx, st, task, err, hashText(input.SourceText)) - return - } - taskResult := map[string]any{ - "title": result.Title, - "summary": result.Summary, - "content_markdown": result.ContentMarkdown, - "content_plain": result.ContentPlain, - "sections": result.Sections, - "source_excerpt": excerpt(input.SourceText, 200), - "can_save": true, - } - ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, "", taskResult, "", "") - if err != nil { - log.Printf("ai document task finish failed: task_id=%s err=%v", task.TaskID, err) - return - } - if !ok { - return - } - userID := taskUserID(task) - _ = st.InsertAIUsageLog(ctx, store.AIUsageLog{ - TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_document", TaskID: task.TaskID, - Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, - PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, - LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.SourceText), OutputHash: hashText(result.ContentMarkdown), - }) - a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "smart_document", result.Title, result.Summary, map[string]any{ - "task_id": task.TaskID, "feature": "smart_document", "status": "succeeded", "progress": 100, "result_type": "text", - "result": compactAITextResult(result.Title, result.Summary, result.ContentMarkdown, result.ContentPlain), - "updated_at": time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func (a *API) runAITableTask(ctx context.Context, st *store.Store, task *store.AITask) { - var input aiTableTaskSnapshot - if !decodeTaskSnapshot(task, &input) { - a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input") - return - } - fileType := strings.ToLower(strings.TrimSpace(input.FileType)) - fileMeta := st.AIFileMetadata(ctx, input.FileID) - profile := a.tableProfileForAIContext(ctx, input.FileID, fileMeta, fileType) - result, err := a.ai.AnalyzeTable(ctx, ai.TableAnalysisInput{ - Topic: input.Topic, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize, Profile: profile, - }) - if err != nil { - a.finishAITaskAIError(ctx, st, task, err, hashText(input.FileID+":"+input.Topic)) - return - } - recordID := strings.TrimSpace(input.RecordID) - if recordID == "" { - recordID = "airec_tbl_" + randomToken(10) - } - if err := st.SaveAITableAnalysis(ctx, task.TenantCode, taskUserID(task), store.AIRecord{ - RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160), - Status: "succeeded", TaskID: task.TaskID, - }, store.AITableAnalysis{ - RecordID: recordID, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize, - Topic: input.Topic, MetricsJSON: mustJSON(result.Metrics, "[]"), PreviewJSON: mustJSON(result.Preview, "{}"), - AlertsJSON: mustJSON(result.Alerts, "[]"), Interpretation: result.Interpretation, - PromptVersion: "smart_table_v1", Model: result.Usage.Model, - }); err != nil { - a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error()) - return - } - taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation} - ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, recordID, taskResult, "", "") - if err != nil { - log.Printf("ai table task finish failed: task_id=%s err=%v", task.TaskID, err) - return - } - if !ok { - return - } - _ = st.InsertAIUsageLog(ctx, store.AIUsageLog{ - TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_table", TaskID: task.TaskID, - Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, - PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, - LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.FileID + ":" + input.Topic), OutputHash: hashText(result.Interpretation), - }) - a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, "smart_table", result.Title, result.Summary, map[string]any{ - "task_id": task.TaskID, "feature": "smart_table", "status": "succeeded", "progress": 100, "result_type": "record", - "record_id": recordID, - "result": map[string]any{"record_id": recordID, "title": result.Title, "summary": excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160)}, - "updated_at": time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func (a *API) runAIMeetingSummaryTask(ctx context.Context, st *store.Store, task *store.AITask) { - var input aiMeetingSummaryTaskSnapshot - if !decodeTaskSnapshot(task, &input) { - a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input") - return - } - userID := taskUserID(task) - meeting, err := st.Meeting(ctx, userID, input.MeetingID) - if err != nil { - a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error()) - return - } - files, err := st.MeetingNoteFiles(ctx, userID, input.MeetingID) - if err != nil { - a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error()) - return - } - fileNames, fileIDs := meetingSummaryFiles(files, input.FileIDs) - result, err := a.ai.SummarizeMeeting(ctx, ai.MeetingSummaryInput{ - MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano), - Participants: meetingSummaryParticipants(*meeting), Files: fileNames, - }) - if err != nil { - a.finishAITaskAIError(ctx, st, task, err, hashText(input.MeetingID+strings.Join(fileIDs, ","))) - return - } - summaryID := strings.TrimSpace(input.SummaryID) - if summaryID == "" { - summaryID = "aisum_" + randomToken(10) - } - if err := st.UpsertAIMeetingSummary(ctx, task.TenantCode, userID, store.AIMeetingSummary{ - SummaryID: summaryID, MeetingID: input.MeetingID, TaskID: task.TaskID, - ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"), - ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"), - PromptVersion: "meeting_summary_v1", Model: result.Usage.Model, - }); err != nil { - a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error()) - return - } - taskResult := map[string]any{ - "summary_id": summaryID, "meeting_id": input.MeetingID, - "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems, - } - ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, summaryID, taskResult, "", "") - if err != nil { - log.Printf("ai meeting summary task finish failed: task_id=%s err=%v", task.TaskID, err) - return - } - if !ok { - return - } - _ = st.InsertAIUsageLog(ctx, store.AIUsageLog{ - TenantCode: task.TenantCode, UserID: task.UserID, Feature: "meeting_summary", TaskID: task.TaskID, - Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, - PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, - LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.MeetingID + strings.Join(fileIDs, ",")), OutputHash: hashText(mustJSON(result.Conclusions, "[]")), - }) - a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "meeting_summary", meeting.Title, "", map[string]any{ - "task_id": task.TaskID, "feature": "meeting_summary", "status": "succeeded", "progress": 100, "result_type": "summary", - "meeting_id": input.MeetingID, "summary_id": summaryID, - "result": map[string]any{"meeting_id": input.MeetingID, "summary_id": summaryID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems}, - "updated_at": time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func (a *API) finishAITaskAIError(ctx context.Context, st *store.Store, task *store.AITask, err error, inputHash string) { - reason := aiErrorReason(err) - a.finishAITaskFailure(ctx, st, task, reason, err.Error()) - _ = st.InsertAIUsageLog(ctx, store.AIUsageLog{ - TenantCode: task.TenantCode, UserID: task.UserID, Feature: task.Feature, TaskID: task.TaskID, - Provider: "deepseek", Status: "failed", ErrorReason: reason, InputHash: inputHash, - }) -} - -func (a *API) finishAITaskFailure(ctx context.Context, st *store.Store, task *store.AITask, reason, message string) { - ok, err := st.FinishAITaskResult(ctx, task.TaskID, "failed", 100, "", nil, reason, message) - if err != nil { - log.Printf("ai task failure update failed: task_id=%s reason=%s err=%v", task.TaskID, reason, err) - return - } - if !ok { - return - } - a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, task.Feature, "AI 任务失败", message, map[string]any{ - "task_id": task.TaskID, "feature": task.Feature, "status": "failed", "progress": 100, "result_type": "none", - "error": map[string]any{"reason": reason, "message": message}, - "updated_at": time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func (a *API) publishAITaskEvent(ctx context.Context, tenantID int64, tenantCode string, userID int64, taskID, feature, title, summary string, data map[string]any) { - if a == nil || a.events == nil || userID <= 0 || strings.TrimSpace(taskID) == "" { - return - } - event := events.Event{ - EventID: events.StableEventID(tenantID, "ai", "workbench.ai.task.updated", taskID+":"+stringValue(data["status"]), userID), - TenantID: tenantID, - TenantCode: tenantCode, - ActorUserID: userID, - Module: "ai", - Action: "workbench.ai.task.updated", - ResourceID: taskID, - TargetUserIDs: []int64{userID}, - Title: strings.TrimSpace(title), - Summary: strings.TrimSpace(summary), - OccurredAt: time.Now().UTC().Round(time.Millisecond), - Data: data, - } - pubCtx, cancel := context.WithTimeout(ctx, workbenchEventPublishTimeout) - defer cancel() - if err := a.events.Publish(pubCtx, event); err != nil { - log.Printf("ai task event publish failed: event_id=%s task_id=%s feature=%s err=%v", event.EventID, taskID, feature, err) - } -} - -func aiTaskSnapshot(value any) (sql.NullString, error) { - data, err := json.Marshal(value) - if err != nil { - return sql.NullString{}, err - } - return sql.NullString{String: string(data), Valid: true}, nil -} - -func decodeTaskSnapshot(task *store.AITask, dst any) bool { - if task == nil || !task.InputSnapshot.Valid || strings.TrimSpace(task.InputSnapshot.String) == "" { - return false - } - return json.Unmarshal([]byte(task.InputSnapshot.String), dst) == nil -} - -func (a *API) tableProfileForAIContext(ctx context.Context, fileID string, meta store.AIFileMetadata, fileType string) ai.TableProfile { - if path := resolveTableFilePath(fileID, meta.Location); path != "" { - return tableProfileFromPath(path, fileType) - } - if strings.TrimSpace(meta.Location) == "" { - return ai.TableProfile{Parsed: false, Message: "source file metadata is not available"} - } - path, cleanup, err := a.downloadOSSObjectToTemp(ctx, meta.Location, fileType) - if err != nil { - return ai.TableProfile{Parsed: false, Message: "source file is not available: " + err.Error()} - } - defer cleanup() - return tableProfileFromPath(path, fileType) -} - -func taskUserID(task *store.AITask) int64 { - if task == nil { - return 0 - } - userID, _ := strconv.ParseInt(strings.TrimSpace(task.UserID), 10, 64) - return userID -} - -func compactAITextResult(title, summary, markdown, plain string) map[string]any { - result := map[string]any{"title": title, "summary": summary, "content_plain": plain} - if len([]rune(markdown)) <= 8000 { - result["content_markdown"] = markdown - } - return result -} - -func stringValue(value any) string { - text, _ := value.(string) - return strings.TrimSpace(text) -} diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 54a6549..129eafe 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -1,1184 +1,55 @@ package httpapi import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "log" "net/http" - "strconv" - "strings" - "time" - "git.118im.com/im_team/im_workbench/internal/ai" + "git.118im.com/im_team/im_workbench/internal/accounts" "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/store" + "git.118im.com/im_team/im_workbench/internal/finance" + "git.118im.com/im_team/im_workbench/internal/mall" + "git.118im.com/im_team/im_workbench/internal/portal" + "git.118im.com/im_team/im_workbench/internal/tasks" ) type API struct { - store *store.Store - auth *auth.Authenticator - events events.Publisher - authCfg config.AuthConfig - manager config.ManagerConfig - ai *ai.Service + auth *auth.Authenticator + accounts *accounts.Service + portal *portal.Service + tasks *tasks.Service + mall *mall.Service + finance *finance.Service } -const workbenchEventPublishTimeout = 15 * time.Second - -const ( - maxMailboxSubjectLength = 200 - maxMailboxContentLength = 5000 -) - -func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig, managerCfg config.ManagerConfig, aiService *ai.Service) *API { - if publisher == nil { - publisher = events.NoopPublisher{} - } - return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg, manager: managerCfg, ai: aiService} +func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API { + return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService} } func (a *API) Register(mux *http.ServeMux) { - mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench)) - mux.HandleFunc("/v1/workbench/apps", a.withAuth(a.workbenchApps)) - mux.HandleFunc("/v1/workbench/apps/search", a.withAuth(a.workbenchAppsSearch)) - mux.HandleFunc("/v1/workbench/ai/capabilities", a.withAuth(a.aiCapabilities)) - mux.HandleFunc("/v1/workbench/ai/text/polish", a.withAuth(a.aiTextPolish)) - mux.HandleFunc("/v1/workbench/ai/documents/generate", a.withAuth(a.aiDocumentGenerate)) - mux.HandleFunc("/v1/workbench/ai/documents", a.withAuth(a.aiDocuments)) - mux.HandleFunc("/v1/workbench/ai/documents/", a.withAuth(a.aiDocumentResource)) - mux.HandleFunc("/v1/workbench/ai/table-analyses", a.withAuth(a.aiTableAnalyses)) - mux.HandleFunc("/v1/workbench/ai/table-analyses/", a.withAuth(a.aiTableAnalysisResource)) - mux.HandleFunc("/v1/workbench/ai/records", a.withAuth(a.aiRecords)) - mux.HandleFunc("/v1/workbench/ai/records/", a.withAuth(a.aiRecordResource)) - mux.HandleFunc("/v1/workbench/ai/tasks/", a.withAuth(a.aiTaskResource)) - 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)) - mux.HandleFunc("/internal/admin/workbench/apps", a.withInternalAdminAuth(a.adminWorkbenchApps)) - mux.HandleFunc("/internal/admin/workbench/apps/disable", a.withInternalAdminAuth(a.adminWorkbenchAppDisable)) - mux.HandleFunc("/internal/admin/workbench/apps/reorder", a.withInternalAdminAuth(a.adminWorkbenchAppsReorder)) - mux.HandleFunc("/internal/admin/workbench/organization/departments", a.withInternalAdminAuth(a.adminOrganizationDepartments)) - mux.HandleFunc("/internal/admin/workbench/organization/members", a.withInternalAdminAuth(a.adminOrganizationMembers)) - mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig)) - mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig)) - mux.HandleFunc("/internal/admin/workbench/tenants/initialize", a.withInternalAdminAuth(a.adminInitializeTenant)) + mux.HandleFunc("/v1/auth/register", a.accountRegister) + mux.HandleFunc("/v1/auth/login", a.accountLogin) + mux.HandleFunc("/v1/auth/logout", a.accountLogout) + mux.HandleFunc("/v1/me", a.memberProfile) + mux.HandleFunc("/v1/me/avatar", a.memberAvatar) + mux.HandleFunc("/v1/me/password", a.memberPassword) + mux.HandleFunc("/v1/me/trade-password", a.memberTradePassword) + mux.HandleFunc("/v1/home", a.home) + mux.HandleFunc("/v1/banners", a.banners) + mux.HandleFunc("/v1/contents/", a.content) + mux.HandleFunc("/v1/member-levels", a.memberLevels) + mux.HandleFunc("/v1/task-halls", a.taskHalls) + mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility) + mux.HandleFunc("/v1/tasks/stats", a.taskStats) + mux.HandleFunc("/v1/task-orders/claim", a.taskClaim) + mux.HandleFunc("/v1/task-orders", a.taskOrders) + mux.HandleFunc("/v1/task-orders/", a.taskOrderResource) + mux.HandleFunc("/v1/manual-products", a.manualProducts) + mux.HandleFunc("/v1/manual-products/", a.manualProduct) + mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts) + mux.HandleFunc("/v1/withdrawal-orders/quote", a.withdrawalQuote) + mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders) + mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder) } 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, *store.Store) - -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 - } - st, err := a.store.ForTenant(r.Context(), principal.TenantID, principal.TenantCode) - if err != nil { - writeStoreError(w, r, err) - return - } - next(w, r, principal, st) - } -} - -func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - modules, version, err := st.WorkbenchModules(r.Context(), p.UserID) - if err != nil { - writeStoreError(w, r, err) - return - } - items := workbenchModulePayloads(modules) - if err := a.populateUnread(st, 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, st *store.Store) { - 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 := st.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, st *store.Store) { - 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 := st.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, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")) - items, next, err := st.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, st *store.Store) { - 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 := st.Departments(r.Context(), p.UserID, "root", "", limit(r, 10, 50)) - if err != nil { - writeStoreError(w, r, err) - return - } - members, _, err := st.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, st *store.Store) { - 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 := st.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, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - records, next, stats, err := st.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: parseUserIDQuery(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, st *store.Store) { - 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 json.RawMessage `json:"location"` - Wifi json.RawMessage `json:"wifi"` - Device json.RawMessage `json:"device"` - } - if !decodeBody(w, r, &body) { - return - } - action := strings.TrimSpace(body.Action) - if action == "" || len(action) > 32 { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "action"}) - return - } - ruleID := strings.TrimSpace(body.RuleID) - if len(ruleID) > 64 { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "rule_id"}) - return - } - shiftID := strings.TrimSpace(body.ShiftID) - if shiftID == "" { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "shift_id"}) - return - } - date, err := parseDate(body.Date) - if err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - if maxShiftIDLength := maxAttendanceShiftIDLength(p.UserID, date); len(shiftID) > maxShiftIDLength { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "shift_id", "max_length": maxShiftIDLength}) - 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 - } - location, err := normalizeJSONObject(body.Location) - if err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "location"}) - return - } - wifi, err := normalizeJSONObject(body.Wifi) - if err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "wifi"}) - return - } - device, err := normalizeJSONObject(body.Device) - if err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "device"}) - return - } - record, checkin, err := st.CreateAttendanceCheckin(r.Context(), p.UserID, "attc_"+randomToken(12), store.AttendanceCheckinRequest{ - Date: date, Action: action, RuleID: ruleID, ShiftID: shiftID, - ClientTime: clientTime, Location: location, Wifi: wifi, Device: 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, st *store.Store) { - switch r.Method { - case http.MethodGet: - items, next, err := st.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 := st.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, st *store.Store) { - switch r.Method { - case http.MethodGet: - items, next, err := st.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 userID `json:"recorder_user_id"` - StartAt string `json:"start_at"` - EndAt string `json:"end_at"` - Location string `json:"location"` - AttendeeUserIDs []userID `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 := st.CreateMeeting(r.Context(), p.UserID, store.Meeting{ - MeetingID: "mtg_" + randomToken(12), RecorderID: int64(body.RecorderUserID), Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, - Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey, - }, parseUserIDList(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, st *store.Store) { - 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 := st.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 optionalUserID `json:"recorder_user_id"` - Version int `json:"version"` - } - if !decodeBody(w, r, &body) { - return - } - meeting, err := st.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID.ptr(), 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 := st.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 := st.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, st, meetingID) - case tail == "ai-summary": - a.aiMeetingSummary(w, r, p, st, meetingID) - case tail == "notes/files" && r.Method == http.MethodPost: - a.meetingNoteFiles(w, r, p, st, meetingID) - case strings.HasPrefix(tail, "notes/files/"): - a.meetingNoteFileResource(w, r, p, st, meetingID, strings.TrimPrefix(tail, "notes/files/")) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - items, next, err := st.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, st *store.Store, meetingID string) { - meeting, err := st.Meeting(r.Context(), p.UserID, meetingID) - if err != nil { - writeStoreError(w, r, err) - return - } - files, err := st.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, st *store.Store, meetingID string) { - idempotencyKey, ok := requireIdempotencyKey(w, r) - if !ok { - return - } - var body struct { - FileID string `json:"file_id"` - FileName string `json:"file_name"` - Name string `json:"name"` - MIMEType string `json:"mime_type"` - MIME string `json:"mime"` - FileSize *int64 `json:"file_size"` - Size *int64 `json:"size"` - FileETag string `json:"file_etag"` - ETag string `json:"etag"` - } - if !decodeBody(w, r, &body) { - return - } - fileID := strings.TrimSpace(body.FileID) - if fileID == "" { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) - return - } - file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), store.MeetingNoteFileInput{ - FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag), - }, 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, st *store.Store, 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"` - FileName string `json:"file_name"` - Name string `json:"name"` - MIMEType string `json:"mime_type"` - MIME string `json:"mime"` - FileSize *int64 `json:"file_size"` - Size *int64 `json:"size"` - FileETag string `json:"file_etag"` - ETag string `json:"etag"` - Version int `json:"version"` - } - if !decodeBody(w, r, &body) { - return - } - fileID := strings.TrimSpace(body.FileID) - if fileID == "" { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) - return - } - file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, store.MeetingNoteFileInput{ - FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag), - }, 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 := st.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, st *store.Store) { - switch r.Method { - case http.MethodGet: - reportDate := "" - if rawDate := strings.TrimSpace(r.URL.Query().Get("date")); rawDate != "" { - date, parseErr := parseDate(rawDate) - if parseErr != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - reportDate = date.Format("2006-01-02") - } - items, next, err := st.DailyReports(r.Context(), p.UserID, reportDate, 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 := st.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, st *store.Store) { - 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 := st.DailyReport(r.Context(), p.UserID, reportID) - if err != nil { - writeStoreError(w, r, err) - 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 := st.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.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 == "submit" && r.Method == http.MethodPost: - var body struct { - Version int `json:"version"` - } - if !decodeBody(w, r, &body) { - return - } - report, err := st.SubmitDaily(r.Context(), p.UserID, reportID, 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)) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - 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 - } - stats, err := st.DailyReportStats(r.Context(), date, r.URL.Query().Get("department_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, reportStatsPayload(stats)) -} - -func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - 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 []userID `json:"user_ids"` - } - if !decodeBody(w, r, &body) { - return - } - if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.reminded", strings.TrimSpace(body.Date), - parseUserIDList(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, st *store.Store) { - switch r.Method { - case http.MethodGet: - fromMonth := "" - if rawMonth := strings.TrimSpace(r.URL.Query().Get("from_month")); rawMonth != "" { - if !validReportMonth(rawMonth) { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "from_month"}) - return - } - fromMonth = rawMonth - } - toMonth := "" - if rawMonth := strings.TrimSpace(r.URL.Query().Get("to_month")); rawMonth != "" { - if !validReportMonth(rawMonth) { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "to_month"}) - return - } - toMonth = rawMonth - } - items, next, err := st.MonthlyReports(r.Context(), p.UserID, fromMonth, toMonth, 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 - } - month := strings.TrimSpace(body.Month) - if !validReportMonth(month) { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) - return - } - report, err := st.CreateMonthlyDraft(r.Context(), p.UserID, "mr_"+randomToken(12), 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, st *store.Store) { - 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 := st.MonthlyReport(r.Context(), p.UserID, reportID) - if err != nil { - writeStoreError(w, r, err) - 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 := st.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.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 == "submit" && r.Method == http.MethodPost: - var body struct { - Version int `json:"version"` - } - if !decodeBody(w, r, &body) { - return - } - report, err := st.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.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 == "reopen" && r.Method == http.MethodPost: - var body struct { - Version int `json:"version"` - } - if !decodeBody(w, r, &body) { - return - } - report, err := st.ReopenMonthly(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)) - default: - methodNotAllowed(w, r) - } -} - -func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - month := strings.TrimSpace(r.URL.Query().Get("month")) - if month == "" { - month = time.Now().Format("2006-01") - } - stats, err := st.MonthlyReportStats(r.Context(), month, r.URL.Query().Get("department_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeSuccess(w, r, reportStatsPayload(stats)) -} - -func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { - if r.Method != http.MethodGet { - methodNotAllowed(w, r) - return - } - cfg, err := st.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, st *store.Store) { - 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 - } - subject := strings.TrimSpace(body.Subject) - content := strings.TrimSpace(body.Content) - if ok := validateMailboxSubmission(w, r, subject, content); !ok { - return - } - attachments, _ := json.Marshal(body.AttachmentFileIDs) - submission, err := st.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ - SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: 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 validateMailboxSubmission(w http.ResponseWriter, r *http.Request, subject, content string) bool { - if len([]rune(subject)) > maxMailboxSubjectLength { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": maxMailboxSubjectLength}) - return false - } - if len([]rune(content)) > maxMailboxContentLength { - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": maxMailboxContentLength}) - return false - } - return true -} - -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 - } - event := events.NewWorkbenchEvent(p, requestID(r), module, action, resourceID, targets, title, summary, data) - start := time.Now() - ctx, cancel := context.WithTimeout(r.Context(), workbenchEventPublishTimeout) - defer cancel() - if err := a.events.Publish(ctx, event); err != nil { - log.Printf("workbench event publish failed: event_id=%s module=%s action=%s resource_id=%s trace_id=%s elapsed=%s err=%v", - event.EventID, module, action, resourceID, event.TraceID, time.Since(start).Round(time.Millisecond), err) - writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "workbench event publish failed", map[string]any{ - "event_id": event.EventID, - "module": module, - "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(st *store.Store, r *http.Request, p *auth.Principal, modules []map[string]any) error { - for _, module := range modules { - if module["code"] == "meetings" { - count, err := st.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 parseUserIDQuery(value string) int64 { - parsed, _ := parseUserID(value) - return parsed -} - -func parseDate(value string) (time.Time, error) { - return time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local) -} - -func validReportMonth(value string) bool { - _, err := time.ParseInLocation("2006-01", strings.TrimSpace(value), time.Local) - return err == nil -} - -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 maxAttendanceShiftIDLength(userID int64, date time.Time) int { - prefix := fmt.Sprintf("att_%d_%s_", userID, date.Format("20060102")) - return 64 - len(prefix) -} - -func normalizeJSONObject(raw json.RawMessage) (string, error) { - if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" { - return "{}", nil - } - var text string - if err := json.Unmarshal(raw, &text); err == nil { - text = strings.TrimSpace(text) - if text == "" || text == "null" { - return "{}", nil - } - raw = json.RawMessage(text) - } - var value map[string]any - if err := json.Unmarshal(raw, &value); err != nil { - return "", err - } - normalized, err := json.Marshal(value) - if err != nil { - return "", err - } - return string(normalized), nil -} - -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 != ""} + writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"}) } diff --git a/internal/httpapi/attendance_payload_test.go b/internal/httpapi/attendance_payload_test.go deleted file mode 100644 index efdf720..0000000 --- a/internal/httpapi/attendance_payload_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package httpapi - -import ( - "testing" - "time" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func TestAttendanceCheckinPayloadReturnsJSONObjectFields(t *testing.T) { - now := time.Date(2026, 9, 18, 1, 27, 49, 0, time.UTC) - payload := attendanceCheckinPayload(store.AttendanceCheckin{ - CheckinID: "attc_1", - Action: "check_in", - ClientTime: now, - ServerTime: now, - Result: "normal", - Location: `{"name":"当前位置","latitude":22.929670335639365}`, - Wifi: `{}`, - Device: `{"platform":"ios","device_id":"C7A76C6A-FC57-4CED-B1C1-CE5199944F8C"}`, - }) - - location, ok := payload["location"].(map[string]any) - if !ok { - t.Fatalf("location=%T, want map[string]any", payload["location"]) - } - if location["name"] != "当前位置" { - t.Fatalf("location=%v", location) - } - if _, ok := payload["wifi"].(map[string]any); !ok { - t.Fatalf("wifi=%T, want map[string]any", payload["wifi"]) - } - device, ok := payload["device"].(map[string]any) - if !ok { - t.Fatalf("device=%T, want map[string]any", payload["device"]) - } - if device["platform"] != "ios" { - t.Fatalf("device=%v", device) - } -} diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go deleted file mode 100644 index e8d8df8..0000000 --- a/internal/httpapi/errors.go +++ /dev/null @@ -1,9 +0,0 @@ -package httpapi - -import "errors" - -var ( - ErrNotFound = errors.New("resource not found") - ErrForbidden = errors.New("forbidden") - ErrConflict = errors.New("conflict") -) diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go new file mode 100644 index 0000000..15a29fe --- /dev/null +++ b/internal/httpapi/finance.go @@ -0,0 +1,201 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/finance" + "git.118im.com/im_team/im_workbench/internal/store" +) + +func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodGet, http.MethodPost) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + if r.Method == http.MethodGet { + result, err := a.finance.PaymentAccounts(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) + return + } + var body finance.PaymentAccountInput + if !decodeBody(w, r, &body) { + return + } + result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodPost) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + var body finance.QuoteInput + if !decodeBody(w, r, &body) { + return + } + result, err := a.finance.Quote(r.Context(), principal.TenantID, principal.MemberID, body) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodGet, http.MethodPost) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + if r.Method == http.MethodGet { + page, ok := optionalPositiveInt(w, r, "page") + if !ok { + writeFinanceError(w, r, finance.ErrPageInvalid) + return + } + pageSize, ok := optionalPositiveInt(w, r, "page_size") + if !ok { + writeFinanceError(w, r, finance.ErrPageSizeInvalid) + return + } + result, err := a.finance.WithdrawalOrders(r.Context(), principal.TenantID, principal.MemberID, + page, pageSize, r.URL.Query().Get("status")) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) + return + } + var body finance.CreateWithdrawalInput + if !decodeBody(w, r, &body) { + return + } + result, err := a.finance.CreateWithdrawal(r.Context(), principal.TenantID, principal.MemberID, + r.Header.Get("Idempotency-Key"), body) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) withdrawalOrder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/withdrawal-orders/"), "/") + if orderIDText == "" || strings.Contains(orderIDText, "/") { + writeFinanceError(w, r, finance.ErrOrderIDInvalid) + return + } + orderID, err := strconv.ParseInt(orderIDText, 10, 64) + if err != nil || orderID <= 0 { + writeFinanceError(w, r, finance.ErrOrderIDInvalid) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + result, err := a.finance.WithdrawalOrder(r.Context(), principal.TenantID, principal.MemberID, orderID) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) authenticateFinanceMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) { + if a.finance == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return nil, false + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return nil, false + } + return principal, true +} + +func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, finance.ErrAccountTypeInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "ACCOUNT_TYPE_INVALID", "validation failed", map[string]any{"field": "account_type"}) + case errors.Is(err, finance.ErrAccountFieldsInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAYMENT_ACCOUNT_FIELDS_INVALID", "validation failed", nil) + case errors.Is(err, finance.ErrTradePassword): + writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "trade_password"}) + case errors.Is(err, finance.ErrTradePasswordUnset): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "TRADE_PASSWORD_NOT_SET", "trade password is not set", nil) + case errors.Is(err, finance.ErrRateLimited): + writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many trade password attempts", map[string]any{"retry_after_seconds": 900}) + case errors.Is(err, finance.ErrCurrencyInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"}) + case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil) + case errors.Is(err, finance.ErrIdempotencyKey): + writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"}) + case errors.Is(err, finance.ErrPageInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) + case errors.Is(err, finance.ErrPageSizeInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) + case errors.Is(err, finance.ErrStatusInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"}) + case errors.Is(err, finance.ErrOrderIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"}) + case errors.Is(err, store.ErrPaymentAccountExists): + writeError(w, r, http.StatusConflict, 30040, "PAYMENT_ACCOUNT_EXISTS", "payment account already exists", nil) + case errors.Is(err, store.ErrPaymentAccountInvalid): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "PAYMENT_ACCOUNT_INVALID", "payment account is unavailable or unverified", nil) + case errors.Is(err, store.ErrWithdrawalDisabled): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil) + case errors.Is(err, store.ErrWithdrawalChannel): + writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil) + case errors.Is(err, store.ErrWithdrawalLimit): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil) + case errors.Is(err, store.ErrInsufficientBalance): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil) + case errors.Is(err, store.ErrAccountRestricted): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil) + case errors.Is(err, store.ErrAccountNotFound): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil) + case errors.Is(err, store.ErrWithdrawalNotFound): + writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil) + case errors.Is(err, store.ErrIdempotencyConflict): + writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil) + case errors.Is(err, store.ErrAttachmentInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "QR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "qr_attachment_id"}) + case errors.Is(err, store.ErrConcurrentUpdate): + writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil) + case errors.Is(err, finance.ErrUnavailable): + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/internal_auth.go b/internal/httpapi/internal_auth.go deleted file mode 100644 index 3ce39f7..0000000 --- a/internal/httpapi/internal_auth.go +++ /dev/null @@ -1,72 +0,0 @@ -package httpapi - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "strconv" - "strings" - "time" -) - -const internalSignatureWindow = 5 * time.Minute - -func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) - if err != nil { - writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) - return - } - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(body)) - if err := a.verifyInternalAdminSignature(r, body); err != nil { - writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) - return - } - next(w, r) - } -} - -func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error { - secret := strings.TrimSpace(a.authCfg.InternalSecret) - if secret == "" { - return ErrForbidden - } - serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name")) - if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected { - return ErrForbidden - } - timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp")) - nonce := strings.TrimSpace(r.Header.Get("X-Nonce")) - signature := strings.TrimSpace(r.Header.Get("X-Signature")) - if timestampRaw == "" || nonce == "" || signature == "" { - return ErrForbidden - } - ts, err := strconv.ParseInt(timestampRaw, 10, 64) - if err != nil { - return ErrForbidden - } - when := time.Unix(ts, 0) - if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow { - return ErrForbidden - } - bodyHash := sha256.Sum256(body) - signingText := strings.Join([]string{ - r.Method, - r.URL.RequestURI(), - hex.EncodeToString(bodyHash[:]), - timestampRaw, - nonce, - }, "\n") - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(signingText)) - expected := hex.EncodeToString(mac.Sum(nil)) - if !hmac.Equal([]byte(expected), []byte(signature)) { - return ErrForbidden - } - return nil -} diff --git a/internal/httpapi/mailbox_test.go b/internal/httpapi/mailbox_test.go deleted file mode 100644 index 3efac6f..0000000 --- a/internal/httpapi/mailbox_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil) - rec := httptest.NewRecorder() - - if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) { - t.Fatal("validateMailboxSubmission returned true for too-long content") - } - if rec.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - var body apiError - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if body.Reason != reasonValidationFailed { - t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String()) - } - if body.Details["field"] != "content" { - t.Fatalf("details=%v", body.Details) - } -} - -func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil) - rec := httptest.NewRecorder() - - writeStoreError(rec, req, store.ErrPolicy) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - var body apiError - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if body.Reason != reasonValidationFailed { - t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String()) - } -} diff --git a/internal/httpapi/manual_products.go b/internal/httpapi/manual_products.go new file mode 100644 index 0000000..8f43452 --- /dev/null +++ b/internal/httpapi/manual_products.go @@ -0,0 +1,104 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "git.118im.com/im_team/im_workbench/internal/mall" + "git.118im.com/im_team/im_workbench/internal/store" +) + +func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if a.mall == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + page, ok := optionalPositiveInt(w, r, "page") + if !ok { + writeMallError(w, r, mall.ErrPageInvalid) + return + } + pageSize, ok := optionalPositiveInt(w, r, "page_size") + if !ok { + writeMallError(w, r, mall.ErrPageSizeInvalid) + return + } + categoryID, ok := optionalNonNegativeInt64(r.URL.Query().Get("category_id")) + if !ok { + writeMallError(w, r, mall.ErrCategoryIDInvalid) + return + } + result, err := a.mall.Products(r.Context(), principal.TenantID, categoryID, page, pageSize) + if err != nil { + writeMallError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) manualProduct(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if a.mall == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return + } + productIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/manual-products/"), "/") + if productIDText == "" || strings.Contains(productIDText, "/") { + writeMallError(w, r, mall.ErrProductIDInvalid) + return + } + productID, err := strconv.ParseInt(productIDText, 10, 64) + if err != nil || productID <= 0 { + writeMallError(w, r, mall.ErrProductIDInvalid) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + result, err := a.mall.Product(r.Context(), principal.TenantID, productID) + if err != nil { + writeMallError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func optionalNonNegativeInt64(value string) (int64, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, true + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil && parsed >= 0 +} + +func writeMallError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, mall.ErrCategoryIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "CATEGORY_ID_INVALID", "validation failed", map[string]any{"field": "category_id"}) + case errors.Is(err, mall.ErrProductIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PRODUCT_ID_INVALID", "validation failed", map[string]any{"field": "product_id"}) + case errors.Is(err, mall.ErrPageInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) + case errors.Is(err, mall.ErrPageSizeInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) + case errors.Is(err, store.ErrResourceNotFound): + writeError(w, r, http.StatusNotFound, 30044, "PRODUCT_NOT_FOUND", "manual product not found", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go deleted file mode 100644 index abad099..0000000 --- a/internal/httpapi/payload.go +++ /dev/null @@ -1,547 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "mime" - "path/filepath" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func workbenchModulePayloads(modules []store.WorkbenchModule) []map[string]any { - items := make([]map[string]any, 0, len(modules)) - for _, module := range modules { - items = append(items, map[string]any{ - "code": module.Code, - "name": module.Name, - "icon_file_id": module.IconFileID, - "route": module.Route, - "sort_order": module.SortOrder, - "unread_count": module.UnreadCount, - "enabled": module.Enabled, - "permission": firstNonEmpty(module.Permission, "enabled"), - }) - } - return items -} - -func organizationDepartmentPayloads(departments []store.OrganizationDepartment) []map[string]any { - items := make([]map[string]any, 0, len(departments)) - for _, department := range departments { - items = append(items, organizationDepartmentPayload(department)) - } - return items -} - -func organizationDepartmentPayload(department store.OrganizationDepartment) map[string]any { - return map[string]any{ - "department_id": department.DepartmentID, - "name": department.Name, - "parent_id": department.ParentID, - "child_count": department.ChildCount, - "member_count": department.MemberCount, - "version": department.Version, - } -} - -func organizationMemberPayloads(members []store.OrganizationMember) []map[string]any { - items := make([]map[string]any, 0, len(members)) - for _, member := range members { - items = append(items, organizationMemberPayload(member)) - } - return items -} - -func organizationMemberPayload(member store.OrganizationMember) map[string]any { - payload := map[string]any{ - "user_id": formatUserID(member.UserID), - "department_id": member.DepartmentID, - "display_name": member.DisplayName, - "position": member.Position, - "avatar_file_id": member.AvatarFileID, - "status": member.Status, - "contactable": member.Contactable, - "version": member.Version, - } - if member.Mobile != "" { - payload["mobile"] = member.Mobile - } - if member.Email != "" { - payload["email"] = member.Email - } - return payload -} - -func attendanceConfigPayload(config *store.AttendanceConfig) map[string]any { - return map[string]any{ - "date": config.Date.Format("2006-01-02"), - "timezone": config.Timezone, - "rule": attendanceRulePayload(config.Rule), - "shifts": attendanceShiftPayloads(config.Shifts), - "allowed_actions": []string{"check_in", "check_out"}, - "today_record": attendanceRecordPayload(config.TodayRecord), - "permissions": map[string]any{ - "can_checkin": true, - "can_view_managed": true, - "can_appeal": config.Rule.AppealAllowed, - }, - } -} - -func attendanceRulePayload(rule store.AttendanceRule) map[string]any { - data := map[string]any{ - "rule_id": rule.RuleID, - "name": rule.Name, - "workday": rule.Workday, - "location_required": rule.LocationRequired, - "wifi_required": rule.WifiRequired, - "allow_outside_checkin": rule.AllowOutsideCheckin, - "appeal_allowed": rule.AppealAllowed, - } - if rule.AppealDeadline != nil { - data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano) - } - return data -} - -func attendanceShiftPayloads(shifts []store.AttendanceShift) []map[string]any { - items := make([]map[string]any, 0, len(shifts)) - for _, shift := range shifts { - items = append(items, map[string]any{ - "shift_id": shift.ShiftID, - "name": shift.Name, - "work_start": shift.WorkStart, - "work_end": shift.WorkEnd, - "checkin_start": shift.CheckinStart, - "checkout_end": shift.CheckoutEnd, - }) - } - return items -} - -func attendanceRecordPayloads(records []store.AttendanceRecord) []map[string]any { - items := make([]map[string]any, 0, len(records)) - for i := range records { - items = append(items, attendanceRecordPayload(&records[i])) - } - return items -} - -func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any { - if record == nil { - return nil - } - return map[string]any{ - "record_id": record.RecordID, - "user_id": formatUserID(record.UserID), - "date": record.Date.Format("2006-01-02"), - "rule_id": record.RuleID, - "shift_id": record.ShiftID, - "status": record.Status, - "checkins": attendanceCheckinPayloads(record.Checkins), - "exceptions": jsonList(record.Exceptions), - "appeal_status": record.AppealStatus, - "version": record.Version, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), - } -} - -func attendanceCheckinPayloads(checkins []store.AttendanceCheckin) []map[string]any { - items := make([]map[string]any, 0, len(checkins)) - for _, checkin := range checkins { - items = append(items, attendanceCheckinPayload(checkin)) - } - return items -} - -func attendanceCheckinPayload(checkin store.AttendanceCheckin) map[string]any { - return map[string]any{ - "checkin_id": checkin.CheckinID, - "action": checkin.Action, - "client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano), - "server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano), - "result": checkin.Result, - "location": jsonObject(checkin.Location), - "wifi": jsonObject(checkin.Wifi), - "device": jsonObject(checkin.Device), - } -} - -func attendanceStatsPayload(stats *store.AttendanceStats) map[string]any { - if stats == nil { - return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0} - } - return map[string]any{ - "expected_count": stats.ExpectedCount, - "checked_count": stats.CheckedCount, - "exception_count": stats.ExceptionCount, - "not_checked_count": stats.NotCheckedCount, - } -} - -func attendanceAppealPayloads(appeals []store.AttendanceAppeal) []map[string]any { - items := make([]map[string]any, 0, len(appeals)) - for i := range appeals { - items = append(items, attendanceAppealPayload(&appeals[i])) - } - return items -} - -func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any { - if appeal == nil { - return nil - } - data := map[string]any{ - "appeal_id": appeal.AppealID, - "record_id": appeal.RecordID, - "user_id": formatUserID(appeal.UserID), - "appeal_type": appeal.AppealType, - "reason": appeal.Reason, - "target_action": appeal.TargetAction, - "attachment_file_ids": jsonList(appeal.AttachmentFileIDs), - "status": appeal.Status, - "version": appeal.Version, - "submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano), - "created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if appeal.TargetTime != nil { - data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano) - } - return data -} - -func meetingPayloads(meetings []store.Meeting, viewer int64) []map[string]any { - items := make([]map[string]any, 0, len(meetings)) - for _, meeting := range meetings { - items = append(items, meetingPayload(meeting, viewer)) - } - return items -} - -func meetingPayload(meeting store.Meeting, viewer int64) map[string]any { - participants := meetingParticipantPayloads(meeting.Participants) - recorderID := meeting.RecorderID - if recorderID == 0 { - recorderID = meeting.OrganizerID - } - organizer := userSummary(meeting.OrganizerID, meeting.Organizer) - recorder := userSummary(recorderID, meeting.Recorder) - state := meetingState(meeting) - data := map[string]any{ - "meeting_id": meeting.MeetingID, - "title": meeting.Title, - "organizer_id": formatUserID(meeting.OrganizerID), - "organizer": organizer, - "organizer_display_name": organizer["display_name"], - "recorder_user_id": formatUserID(recorderID), - "recorder": recorder, - "recorder_display_name": recorder["display_name"], - "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), - "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), - "meeting_time": timeRange(meeting.StartAt, meeting.EndAt), - "time_range": timeRange(meeting.StartAt, meeting.EndAt), - "location": meeting.Location, - "state": state, - "reminder_minutes": jsonList(meeting.ReminderMinutes), - "version": meeting.Version, - "participants": participants, - "participant_count": len(participants), - "participant_names": meetingParticipantNames(participants), - "permissions": meetingPermissions(meeting, viewer), - "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if meeting.CanceledAt != nil { - data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) - } - for _, participant := range meeting.Participants { - if participant.UserID == viewer { - data["my_response"] = participant.Response - break - } - } - return data -} - -func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any { - items := make([]map[string]any, 0, len(participants)) - for _, participant := range participants { - summary := userSummary(participant.UserID, participant.User) - item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]} - if avatarFileID, ok := summary["avatar_file_id"]; ok { - item["avatar_file_id"] = avatarFileID - } - if participant.ResponseAt != nil { - item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) - } - items = append(items, item) - } - return items -} - -func meetingNoteListPayloads(meetings []store.Meeting, viewer int64) []map[string]any { - items := make([]map[string]any, 0, len(meetings)) - for _, meeting := range meetings { - item := meetingPayload(meeting, viewer) - item["note_status"] = "empty" - item["file_count"] = 0 - item["permissions"] = meetingNotePermissions(meeting, viewer) - items = append(items, item) - } - return items -} - -func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteFile, viewer int64) map[string]any { - meetingData := meetingPayload(meeting, viewer) - fileItems := make([]map[string]any, 0, len(files)) - for _, file := range files { - fileItems = append(fileItems, meetingNoteFilePayload(file, viewer)) - } - data := map[string]any{} - for k, v := range meetingData { - data[k] = v - } - data["meeting"] = meetingData - data["note_status"] = "empty" - if len(files) > 0 { - data["note_status"] = "available" - } - data["file_count"] = len(files) - data["files"] = fileItems - data["permissions"] = meetingNotePermissions(meeting, viewer) - return data -} - -func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any { - extension := meetingNoteFileExtension(file.FileName, file.MIMEType) - return map[string]any{ - "note_file_id": file.NoteFileID, - "meeting_id": file.MeetingID, - "file_id": file.FileID, - "name": file.FileName, - "file_name": file.FileName, - "mime": file.MIMEType, - "mime_type": file.MIMEType, - "size": file.FileSize, - "file_size": file.FileSize, - "file_extension": extension, - "file_etag": file.FileETag, - "status": file.Status, - "preview_status": file.PreviewStatus, - "preview_mode": file.PreviewMode, - "preview_file_id": file.PreviewFileID, - "uploader_user_id": formatUserID(file.UploaderID), - "uploader": userSummary(file.UploaderID, file.Uploader), - "version": file.Version, - "permissions": map[string]any{ - "can_preview": file.Status == "active", - "can_download": file.Status == "active", - "can_replace": file.Status == "active" && file.UploaderID == viewer, - "can_delete": file.Status == "active" && file.UploaderID == viewer, - }, - "created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano), - } -} - -func meetingNoteFileExtension(fileName, mimeType string) string { - if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(fileName)), ".")); ext != "" { - return ext - } - exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType)) - if len(exts) == 0 { - return "" - } - return strings.ToLower(strings.TrimPrefix(exts[0], ".")) -} - -func dailyReportPayloads(reports []store.DailyReport) []map[string]any { - items := make([]map[string]any, 0, len(reports)) - for _, report := range reports { - items = append(items, dailyReportPayload(report)) - } - return items -} - -func dailyReportPayload(report store.DailyReport) map[string]any { - data := map[string]any{ - "report_id": report.ReportID, - "author_id": formatUserID(report.AuthorID), - "report_date": report.ReportDate.Format("2006-01-02"), - "content": report.Content, - "content_short": preview(report.Content), - "attachments": jsonList(report.Attachments), - "status": report.Status, - "read_state": report.ReadState, - "version": report.Version, - "draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - "created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if report.SubmittedAt != nil { - data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) - } - return data -} - -func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any { - items := make([]map[string]any, 0, len(reports)) - for _, report := range reports { - items = append(items, monthlyReportPayload(report)) - } - return items -} - -func monthlyReportPayload(report store.MonthlyReport) map[string]any { - data := map[string]any{ - "report_id": report.ReportID, - "author_id": formatUserID(report.AuthorID), - "month": report.Month, - "work_summary": report.WorkSummary, - "next_plan": report.NextPlan, - "attachments": jsonList(report.Attachments), - "status": report.Status, - "read_state": report.ReadState, - "version": report.Version, - "draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - "created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if report.SubmittedAt != nil { - data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) - } - return data -} - -func reportStatsPayload(stats *store.ReportStats) map[string]any { - if stats == nil { - return map[string]any{ - "expected_count": 0, - "submitted_count": 0, - "missing_count": 0, - "scope": map[string]any{"department_id": ""}, - } - } - return map[string]any{ - "expected_count": stats.ExpectedCount, - "submitted_count": stats.SubmittedCount, - "missing_count": stats.MissingCount, - "scope": map[string]any{"department_id": stats.DepartmentID}, - } -} - -func mailboxConfigPayload(config store.MailboxConfig) map[string]any { - return map[string]any{ - "enabled": config.Enabled, - "allow_anonymous": config.AllowAnonymous, - "required_fields": jsonList(config.RequiredFields), - "max_length": config.MaxLength, - "attachment_policy": jsonObject(config.AttachmentPolicy), - "notice": map[string]any{ - "title": config.NoticeTitle, - "content": config.NoticeContent, - "version": config.Version, - }, - } -} - -func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any { - return map[string]any{ - "submission_id": submission.SubmissionID, - "sender_id": formatUserID(submission.SenderID), - "subject": submission.Subject, - "anonymous": submission.Anonymous, - "attachment_file_ids": jsonList(submission.AttachmentFileIDs), - "notice_version": submission.NoticeVersion, - "status": submission.Status, - "submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano), - } -} - -func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any { - isOrganizer := meeting.OrganizerID == viewer - isScheduled := meetingState(meeting) == "scheduled" - return map[string]any{ - "can_update": isOrganizer && isScheduled, - "can_cancel": isOrganizer && isScheduled, - "can_remind": isOrganizer && isScheduled, - "can_respond": !isOrganizer && isScheduled, - } -} - -func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any { - recorderID := meeting.RecorderID - if recorderID == 0 { - recorderID = meeting.OrganizerID - } - readable := meetingState(meeting) == "ended" - canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer) - return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true} -} - -func userSummary(userID int64, summary store.UserSummary) map[string]any { - displayName := strings.TrimSpace(summary.DisplayName) - if displayName == "" { - displayName = formatUserID(userID) - } - data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName} - if strings.TrimSpace(summary.AvatarFileID) != "" { - data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID) - } - return data -} - -func meetingState(meeting store.Meeting) string { - state := strings.TrimSpace(meeting.State) - if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) { - return "ended" - } - return state -} - -func meetingParticipantNames(participants []map[string]any) []string { - names := make([]string, 0, len(participants)) - for _, participant := range participants { - name, _ := participant["display_name"].(string) - name = strings.TrimSpace(name) - if name != "" { - names = append(names, name) - } - } - return names -} - -func jsonList(raw string) []any { - var items []any - if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &items); err != nil { - return []any{} - } - return items -} - -func jsonObject(raw string) map[string]any { - var item map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &item); err != nil { - return map[string]any{} - } - return item -} - -func preview(value string) string { - runes := []rune(strings.TrimSpace(value)) - if len(runes) <= 40 { - return string(runes) - } - return string(runes[:40]) -} - -func timeRange(startAt, endAt time.Time) string { - if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") { - return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04") - } - return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04") -} diff --git a/internal/httpapi/payload_userid_test.go b/internal/httpapi/payload_userid_test.go deleted file mode 100644 index b815e4d..0000000 --- a/internal/httpapi/payload_userid_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package httpapi - -import ( - "strings" - "testing" - "time" - - "git.118im.com/im_team/im_workbench/internal/store" -) - -func TestPayloadUserIDsAreStrings(t *testing.T) { - now := time.Date(2026, 9, 16, 9, 30, 0, 0, time.UTC) - userA, err := parseUserID("usrag7w9xJ6hto") - if err != nil { - t.Fatal(err) - } - userB, err := parseUserID("usr1RqTFgHSenM") - if err != nil { - t.Fatal(err) - } - - payloads := []map[string]any{ - organizationMemberPayload(store.OrganizationMember{UserID: userA}), - attendanceRecordPayload(&store.AttendanceRecord{RecordID: "att_1", UserID: userA, Date: now, CreatedAt: now, UpdatedAt: now}), - attendanceAppealPayload(&store.AttendanceAppeal{AppealID: "atta_1", UserID: userA, SubmittedAt: now, CreatedAt: now, UpdatedAt: now}), - meetingPayload(store.Meeting{ - MeetingID: "mtg_1", - OrganizerID: userA, - RecorderID: userB, - StartAt: now, - EndAt: now.Add(time.Hour), - CreatedAt: now, - UpdatedAt: now, - Participants: []store.MeetingParticipant{ - {UserID: userA, Response: "accepted"}, - {UserID: userB, Response: "pending"}, - }, - }, userA), - meetingNoteFilePayload(store.MeetingNoteFile{NoteFileID: "mnote_1", UploaderID: userA, CreatedAt: now, UpdatedAt: now}, userA), - dailyReportPayload(store.DailyReport{ReportID: "dr_1", AuthorID: userA, ReportDate: now, CreatedAt: now, UpdatedAt: now}), - monthlyReportPayload(store.MonthlyReport{ReportID: "mr_1", AuthorID: userA, CreatedAt: now, UpdatedAt: now}), - mailboxSubmissionPayload(store.MailboxSubmission{SubmissionID: "mb_1", SenderID: userA, SubmittedAt: now}), - } - - for _, payload := range payloads { - assertNoNumericUserIDs(t, payload) - } -} - -func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) { - now := time.Now().UTC() - userA, err := parseUserID("usrag7w9xJ6hto") - if err != nil { - t.Fatal(err) - } - userB, err := parseUserID("usr1RqTFgHSenM") - if err != nil { - t.Fatal(err) - } - - payload := meetingPayload(store.Meeting{ - MeetingID: "mtg_1", - Title: "周会", - OrganizerID: userA, - RecorderID: userA, - Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"}, - Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"}, - StartAt: now.Add(-2 * time.Hour), - EndAt: now.Add(-time.Hour), - State: "scheduled", - CreatedAt: now, - UpdatedAt: now, - Participants: []store.MeetingParticipant{ - {UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}}, - }, - }, userA) - - if payload["state"] != "ended" { - t.Fatalf("state=%v, want ended", payload["state"]) - } - if payload["organizer_display_name"] != "张三" { - t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"]) - } - names, ok := payload["participant_names"].([]string) - if !ok || len(names) != 1 || names[0] != "李四" { - t.Fatalf("participant_names=%#v", payload["participant_names"]) - } - participants, ok := payload["participants"].([]map[string]any) - if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" { - t.Fatalf("participants=%#v", payload["participants"]) - } -} - -func TestMeetingNoteFilePayloadKeepsOriginalNameAndExtension(t *testing.T) { - now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) - payload := meetingNoteFilePayload(store.MeetingNoteFile{ - NoteFileID: "mnote_1", - FileID: "file_1", - FileName: "会议纪要.pdf", - MIMEType: "application/pdf", - FileSize: 2048, - UploaderID: 1, - Status: "active", - CreatedAt: now, - UpdatedAt: now, - }, 1) - if payload["file_name"] != "会议纪要.pdf" || payload["name"] != "会议纪要.pdf" { - t.Fatalf("payload names = file_name:%v name:%v", payload["file_name"], payload["name"]) - } - if payload["file_extension"] != "pdf" { - t.Fatalf("file_extension = %v, want pdf", payload["file_extension"]) - } - if payload["mime"] != "application/pdf" || payload["size"] != int64(2048) { - t.Fatalf("payload aliases = mime:%v size:%v", payload["mime"], payload["size"]) - } -} - -func TestMeetingNoteFilePayloadUsesUploaderSummary(t *testing.T) { - now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) - payload := meetingNoteFilePayload(store.MeetingNoteFile{ - NoteFileID: "mnote_1", - UploaderID: 1573637795346386944, - Uploader: store.UserSummary{ - UserID: 1573637795346386944, - DisplayName: "jcharles3", - }, - Status: "active", - CreatedAt: now, - UpdatedAt: now, - }, 1573637795346386944) - uploader, ok := payload["uploader"].(map[string]any) - if !ok { - t.Fatalf("uploader=%#v", payload["uploader"]) - } - if uploader["display_name"] != "jcharles3" { - t.Fatalf("uploader.display_name=%v, want jcharles3", uploader["display_name"]) - } -} - -func assertNoNumericUserIDs(t *testing.T, value any) { - t.Helper() - switch typed := value.(type) { - case map[string]any: - for key, item := range typed { - if isUserIDKey(key) { - if _, ok := item.(string); !ok { - t.Fatalf("%s is %T, want string", key, item) - } - } - assertNoNumericUserIDs(t, item) - } - case []map[string]any: - for _, item := range typed { - assertNoNumericUserIDs(t, item) - } - case []any: - for _, item := range typed { - assertNoNumericUserIDs(t, item) - } - } -} - -func isUserIDKey(key string) bool { - return key == "user_id" || - key == "organizer_id" || - key == "recorder_user_id" || - key == "uploader_user_id" || - key == "author_id" || - key == "sender_id" || - strings.HasSuffix(key, "_user_id") -} diff --git a/internal/httpapi/portal.go b/internal/httpapi/portal.go new file mode 100644 index 0000000..d014be0 --- /dev/null +++ b/internal/httpapi/portal.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "git.118im.com/im_team/im_workbench/internal/portal" + "git.118im.com/im_team/im_workbench/internal/store" +) + +var publicContentKeys = map[string]struct{}{ + "privacy_policy": {}, + "user_agreement": {}, +} + +func (a *API) home(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeProfileError(w, r, err) + return + } + result, err := a.portal.Home(r.Context(), principal.TenantID, principal.MemberID, profile) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) banners(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + position := strings.TrimSpace(r.URL.Query().Get("position")) + if position == "" { + position = "home_top" + } + items, err := a.portal.Banners(r.Context(), principal.TenantID, position) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, items) +} + +func (a *API) content(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + if a.auth == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return + } + key := strings.TrimPrefix(r.URL.Path, "/v1/contents/") + key = strings.ToLower(strings.TrimSpace(key)) + if strings.Contains(key, "/") { + writePortalError(w, r, portal.ErrContentKeyInvalid) + return + } + tenantID := a.auth.DefaultTenantID() + if _, public := publicContentKeys[key]; !public { + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + tenantID = principal.TenantID + } + result, err := a.portal.Content(r.Context(), tenantID, key) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) memberLevels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + items, err := a.portal.MemberLevels(r.Context(), principal.TenantID) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, items) +} + +func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool { + if a.portal != nil { + return true + } + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return false +} + +func writePortalError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, portal.ErrPositionInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"}) + case errors.Is(err, portal.ErrContentKeyInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"}) + case errors.Is(err, store.ErrResourceNotFound): + writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/profile.go b/internal/httpapi/profile.go new file mode 100644 index 0000000..1ef6ae9 --- /dev/null +++ b/internal/httpapi/profile.go @@ -0,0 +1,160 @@ +package httpapi + +import ( + "errors" + "net/http" + + accountsvc "git.118im.com/im_team/im_workbench/internal/accounts" + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/store" +) + +type avatarRequest struct { + AttachmentID int64 `json:"attachment_id"` +} + +type passwordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +type tradePasswordRequest struct { + LoginPassword string `json:"login_password"` + OldTradePassword string `json:"old_trade_password,omitempty"` + NewTradePassword string `json:"new_trade_password"` +} + +func (a *API) memberProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeProfileError(w, r, err) + return + } + writeSuccess(w, r, profile) +} + +func (a *API) memberAvatar(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + methodNotAllowed(w, r, http.MethodPatch) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + var body avatarRequest + if !decodeBody(w, r, &body) { + return + } + avatar, err := a.accounts.ChangeAvatar(r.Context(), principal.TenantID, principal.MemberID, body.AttachmentID) + if err != nil { + writeProfileError(w, r, err) + return + } + writeSuccess(w, r, avatar) +} + +func (a *API) memberPassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + methodNotAllowed(w, r, http.MethodPut) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + var body passwordRequest + if !decodeBody(w, r, &body) { + return + } + err := a.accounts.ChangePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangePasswordInput{ + OldPassword: body.OldPassword, NewPassword: body.NewPassword, + }) + if err != nil { + writeProfileError(w, r, err) + return + } + if err := a.auth.RevokeMemberSessions(r.Context(), principal.TenantID, principal.MemberID); err != nil { + writeProfileError(w, r, err) + return + } + writeSuccess(w, r, nil) +} + +func (a *API) memberTradePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + methodNotAllowed(w, r, http.MethodPut) + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + var body tradePasswordRequest + if !decodeBody(w, r, &body) { + return + } + err := a.accounts.ChangeTradePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangeTradePasswordInput{ + LoginPassword: body.LoginPassword, OldTradePassword: body.OldTradePassword, + NewTradePassword: body.NewTradePassword, + }) + if err != nil { + writeProfileError(w, r, err) + return + } + writeSuccess(w, r, nil) +} + +func (a *API) authenticateMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) { + if a.auth == nil || a.accounts == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return nil, false + } + principal, err := a.auth.Authenticate(r) + if err == nil { + return principal, true + } + if errors.Is(err, auth.ErrForbidden) { + writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil) + } else if errors.Is(err, auth.ErrAuthRequired) { + writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil) + } else { + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } + return nil, false +} + +func writeProfileError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, accountsvc.ErrAttachmentInvalid), errors.Is(err, store.ErrAttachmentInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "AVATAR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "attachment_id"}) + case errors.Is(err, accountsvc.ErrCurrentPassword): + writeError(w, r, http.StatusBadRequest, 30000, "CURRENT_PASSWORD_INVALID", "current password is invalid", map[string]any{"field": "old_password"}) + case errors.Is(err, accountsvc.ErrLoginPassword): + writeError(w, r, http.StatusBadRequest, 30000, "LOGIN_PASSWORD_INVALID", "login password is invalid", map[string]any{"field": "login_password"}) + case errors.Is(err, accountsvc.ErrPasswordInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "NEW_PASSWORD_INVALID", "validation failed", map[string]any{"field": "new_password"}) + case errors.Is(err, accountsvc.ErrOldTradePasswordUnset): + writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_REQUIRED", "old trade password is required", map[string]any{"field": "old_trade_password"}) + case errors.Is(err, accountsvc.ErrOldTradePassword): + writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_INVALID", "old trade password is invalid", map[string]any{"field": "old_trade_password"}) + case errors.Is(err, accountsvc.ErrTradePassword): + writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "new_trade_password"}) + case errors.Is(err, store.ErrAccountNotFound): + writeError(w, r, http.StatusNotFound, 30044, "ACCOUNT_NOT_FOUND", "account not found", nil) + case errors.Is(err, store.ErrConcurrentUpdate): + writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil) + case errors.Is(err, accountsvc.ErrUnavailable): + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go index a43a1f6..6056e36 100644 --- a/internal/httpapi/response.go +++ b/internal/httpapi/response.go @@ -2,22 +2,15 @@ package httpapi import ( "encoding/json" - "errors" "net/http" + "strings" "time" - - "git.118im.com/im_team/im_workbench/internal/auth" - "git.118im.com/im_team/im_workbench/internal/store" ) const ( reasonMalformedRequest = "MALFORMED_REQUEST" reasonMethodNotAllowed = "METHOD_NOT_ALLOWED" - reasonAuthRequired = "AUTH_REQUIRED" reasonForbidden = "FORBIDDEN" - reasonNotFound = "RESOURCE_NOT_FOUND" - reasonConflict = "CONFLICT" - reasonValidationFailed = "VALIDATION_FAILED" reasonInternal = "INTERNAL" ) @@ -32,44 +25,20 @@ type apiError struct { 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), + "code": 0, "data": data, "request_id": requestID(r), "server_time": time.Now().UTC().Format(time.RFC3339Nano), }) } func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) { - writeJSON(w, r, status, apiError{ - Code: code, - Reason: reason, - Message: message, - Details: details, - RequestID: requestID(r), - ServerTime: time.Now().UTC().Format(time.RFC3339Nano), - }) -} - -func writeStoreError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, ErrNotFound), errors.Is(err, store.ErrNotFound): - writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) - case errors.Is(err, ErrForbidden), errors.Is(err, store.ErrForbidden), errors.Is(err, auth.ErrForbidden): - writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) - case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict): - writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil) - case errors.Is(err, store.ErrPolicy): - writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) - default: - writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) - } + writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details, + RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)}) } 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) + if requestID := requestID(r); requestID != "" { + w.Header().Set("X-Request-ID", requestID) } w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) @@ -79,20 +48,21 @@ 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 - } +func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil) + return false } - return "" + return true } -func firstNonNilInt64(values ...*int64) *int64 { - for _, value := range values { - if value != nil { - return value - } +func methodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) { + if len(allowed) == 0 { + allowed = []string{http.MethodPost} } - return nil + w.Header().Set("Allow", strings.Join(allowed, ", ")) + writeError(w, r, http.StatusMethodNotAllowed, 30005, reasonMethodNotAllowed, "method not allowed", nil) } diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go new file mode 100644 index 0000000..2442323 --- /dev/null +++ b/internal/httpapi/tasks.go @@ -0,0 +1,231 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/store" + "git.118im.com/im_team/im_workbench/internal/tasks" +) + +type taskClaimRequest struct { + HallID int64 `json:"hall_id"` +} + +func (a *API) taskHalls(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + result, err := a.tasks.Halls(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) taskEligibility(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + hallID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("hall_id")), 10, 64) + if err != nil { + writeTaskError(w, r, tasks.ErrHallIDInvalid) + return + } + result, err := a.tasks.Eligibility(r.Context(), principal.TenantID, principal.MemberID, hallID) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) taskStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + result, err := a.tasks.Stats(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) taskClaim(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodPost) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + var body taskClaimRequest + if !decodeBody(w, r, &body) { + return + } + result, err := a.tasks.Claim(r.Context(), principal.TenantID, principal.MemberID, body.HallID) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) taskOrders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + page, ok := optionalPositiveInt(w, r, "page") + if !ok { + writeTaskError(w, r, tasks.ErrPageInvalid) + return + } + pageSize, ok := optionalPositiveInt(w, r, "page_size") + if !ok { + writeTaskError(w, r, tasks.ErrPageSizeInvalid) + return + } + result, err := a.tasks.Orders(r.Context(), principal.TenantID, principal.MemberID, page, pageSize, r.URL.Query().Get("status")) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) taskOrderResource(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/task-orders/"), "/") + parts := strings.Split(path, "/") + if len(parts) < 1 || len(parts) > 2 || parts[0] == "" { + writeTaskError(w, r, tasks.ErrOrderIDInvalid) + return + } + orderID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || orderID <= 0 { + writeTaskError(w, r, tasks.ErrOrderIDInvalid) + return + } + principal, ok := a.authenticateTaskMember(w, r) + if !ok { + return + } + if len(parts) == 1 { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + result, err := a.tasks.Order(r.Context(), principal.TenantID, principal.MemberID, orderID) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) + return + } + if parts[1] != "submissions" { + writeTaskError(w, r, store.ErrOrderNotFound) + return + } + if r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodPost) + return + } + var body tasks.SubmitInput + if !decodeBody(w, r, &body) { + return + } + result, err := a.tasks.Submit(r.Context(), principal.TenantID, principal.MemberID, orderID, body) + if err != nil { + writeTaskError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) authenticateTaskMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) { + if a.tasks == nil { + writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil) + return nil, false + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return nil, false + } + return principal, true +} + +func optionalPositiveInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) { + value := strings.TrimSpace(r.URL.Query().Get(name)) + if value == "" { + return 0, true + } + parsed, err := strconv.Atoi(value) + return parsed, err == nil && parsed > 0 +} + +func writeTaskError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, tasks.ErrHallIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "HALL_ID_INVALID", "validation failed", map[string]any{"field": "hall_id"}) + case errors.Is(err, tasks.ErrOrderIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"}) + case errors.Is(err, tasks.ErrPageInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) + case errors.Is(err, tasks.ErrPageSizeInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) + case errors.Is(err, tasks.ErrStatusInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "ORDER_STATUS_INVALID", "validation failed", map[string]any{"field": "status"}) + case errors.Is(err, tasks.ErrEvidenceInvalid), errors.Is(err, store.ErrEvidenceInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "EVIDENCE_INVALID", "validation failed", map[string]any{"field": "evidence"}) + case errors.Is(err, tasks.ErrTaskDisabled), errors.Is(err, store.ErrTaskDisabled): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "TASK_DISABLED", "task feature is disabled", nil) + case errors.Is(err, tasks.ErrOutsideBusinessHour), errors.Is(err, store.ErrOutsideBusinessHours): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "OUTSIDE_BUSINESS_HOURS", "outside business hours", nil) + case errors.Is(err, store.ErrInsufficientBalance): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil) + case errors.Is(err, store.ErrDailyLimitReached): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "DAILY_LIMIT_REACHED", "daily claim limit reached", nil) + case errors.Is(err, store.ErrLevelNotEligible), errors.Is(err, store.ErrTaskHallNotFound): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "LEVEL_NOT_ELIGIBLE", "member level is not eligible", nil) + case errors.Is(err, store.ErrAccountNotFound), errors.Is(err, store.ErrAccountRestricted): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil) + case errors.Is(err, store.ErrPendingOrderExists): + writeError(w, r, http.StatusConflict, 30040, "PENDING_ORDER_EXISTS", "unfinished order exists", nil) + case errors.Is(err, store.ErrNoTaskAvailable): + writeError(w, r, http.StatusServiceUnavailable, 30053, "NO_TASK_AVAILABLE", "no task is currently available", nil) + case errors.Is(err, store.ErrOrderNotFound): + writeError(w, r, http.StatusNotFound, 30044, "ORDER_NOT_FOUND", "task order not found", nil) + case errors.Is(err, store.ErrOrderState): + writeError(w, r, http.StatusConflict, 30040, "ORDER_STATE_INVALID", "task order state does not allow this operation", nil) + case errors.Is(err, store.ErrOrderExpired): + writeError(w, r, http.StatusConflict, 30040, "ORDER_EXPIRED", "task order has expired", nil) + default: + writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) + } +} diff --git a/internal/httpapi/uid.go b/internal/httpapi/uid.go deleted file mode 100644 index 7c776ad..0000000 --- a/internal/httpapi/uid.go +++ /dev/null @@ -1,142 +0,0 @@ -package httpapi - -import ( - "encoding/base64" - "encoding/binary" - "encoding/json" - "fmt" - "strconv" - "strings" - "sync" - - "golang.org/x/crypto/xtea" -) - -type userID int64 - -const defaultUIDKey = "dkXxWLwqUNRfYmdXRzOkZw==" - -var ( - uidCodecMu sync.RWMutex - uidCipher *xtea.Cipher -) - -func init() { - if err := SetUIDKey(defaultUIDKey); err != nil { - panic(err) - } -} - -func SetUIDKey(encoded string) error { - key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return fmt.Errorf("invalid uid key: %w", err) - } - cipher, err := xtea.NewCipher(key) - if err != nil { - return fmt.Errorf("invalid uid key: %w", err) - } - uidCodecMu.Lock() - uidCipher = cipher - uidCodecMu.Unlock() - return nil -} - -func (id *userID) UnmarshalJSON(raw []byte) error { - value, err := parseUserIDJSON(raw) - if err != nil { - return err - } - *id = userID(value) - return nil -} - -type optionalUserID struct { - value int64 - set bool -} - -func (id *optionalUserID) UnmarshalJSON(raw []byte) error { - if strings.TrimSpace(string(raw)) == "null" { - return nil - } - value, err := parseUserIDJSON(raw) - if err != nil { - return err - } - id.set = true - id.value = value - return nil -} - -func (id optionalUserID) ptr() *int64 { - if !id.set { - return nil - } - return &id.value -} - -func parseUserIDJSON(raw []byte) (int64, error) { - var text string - if err := json.Unmarshal(raw, &text); err == nil { - return parseUserID(text) - } - return 0, fmt.Errorf("user id must be a string") -} - -func parseUserID(value string) (int64, error) { - value = strings.TrimSpace(value) - if value == "" { - return 0, fmt.Errorf("empty user id") - } - if strings.HasPrefix(value, "usr") { - decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(strings.TrimPrefix(value, "usr")) - if err != nil || len(decoded) < 8 { - return 0, fmt.Errorf("invalid user id") - } - return decryptUID(decoded[:8]), nil - } - return strconv.ParseInt(value, 10, 64) -} - -func formatUserID(id int64) string { - if id == 0 { - return "" - } - encoded := encryptUID(id) - return "usr" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encoded) -} - -func parseUserIDList(values []userID) []int64 { - items := make([]int64, 0, len(values)) - seen := make(map[int64]bool, len(values)) - for _, value := range values { - id := int64(value) - if id == 0 || seen[id] { - continue - } - seen[id] = true - items = append(items, id) - } - return items -} - -func decryptUID(encoded []byte) int64 { - dst := make([]byte, 8) - uidCodecMu.RLock() - cipher := uidCipher - uidCodecMu.RUnlock() - cipher.Decrypt(dst, encoded[:8]) - return int64(binary.LittleEndian.Uint64(dst)) -} - -func encryptUID(id int64) []byte { - src := make([]byte, 8) - dst := make([]byte, 8) - binary.LittleEndian.PutUint64(src, uint64(id)) - uidCodecMu.RLock() - cipher := uidCipher - uidCodecMu.RUnlock() - cipher.Encrypt(dst, src) - return dst -} diff --git a/internal/httpapi/uid_test.go b/internal/httpapi/uid_test.go deleted file mode 100644 index c4ff393..0000000 --- a/internal/httpapi/uid_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "testing" -) - -func TestUserIDJSONAcceptsIMUserID(t *testing.T) { - var body struct { - RecorderUserID userID `json:"recorder_user_id"` - AttendeeUserIDs []userID `json:"attendee_user_ids"` - } - raw := []byte(`{"recorder_user_id":"usrag7w9xJ6hto","attendee_user_ids":["usr1RqTFgHSenM"]}`) - if err := json.Unmarshal(raw, &body); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if body.RecorderUserID == 0 || len(body.AttendeeUserIDs) != 1 || body.AttendeeUserIDs[0] == 0 { - t.Fatalf("decoded zero ids: %+v", body) - } - if got := formatUserID(int64(body.RecorderUserID)); got != "usrag7w9xJ6hto" { - t.Fatalf("roundtrip recorder=%q", got) - } -} - -func TestUserIDCodecMatchesIMServer(t *testing.T) { - const ( - publicID = "usr3rZjh8TD_34" - dbID = int64(1574592702383656960) - ) - got, err := parseUserID(publicID) - if err != nil { - t.Fatalf("parseUserID: %v", err) - } - if got != dbID { - t.Fatalf("parseUserID(%q)=%d, want %d", publicID, got, dbID) - } - if formatted := formatUserID(dbID); formatted != publicID { - t.Fatalf("formatUserID(%d)=%q, want %q", dbID, formatted, publicID) - } -} - -func TestUserIDJSONRejectsNumericID(t *testing.T) { - var id userID - if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil { - t.Fatal("expected numeric user id to be rejected") - } -} diff --git a/internal/mall/errors.go b/internal/mall/errors.go new file mode 100644 index 0000000..05741d9 --- /dev/null +++ b/internal/mall/errors.go @@ -0,0 +1,10 @@ +package mall + +import "errors" + +var ( + ErrCategoryIDInvalid = errors.New("category id invalid") + ErrProductIDInvalid = errors.New("product id invalid") + ErrPageInvalid = errors.New("page invalid") + ErrPageSizeInvalid = errors.New("page size invalid") +) diff --git a/internal/mall/service.go b/internal/mall/service.go new file mode 100644 index 0000000..a5f9e40 --- /dev/null +++ b/internal/mall/service.go @@ -0,0 +1,149 @@ +package mall + +import ( + "context" + "html" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 + maxSummaryRunes = 120 +) + +var ( + scriptBlockPattern = regexp.MustCompile(`(?is)]*>.*?`) + styleBlockPattern = regexp.MustCompile(`(?is)]*>.*?`) + htmlTagPattern = regexp.MustCompile(`(?s)<[^>]*>`) +) + +type Repository interface { + ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) + ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) +} + +type Service struct { + repository Repository +} + +type Product struct { + ID int64 `json:"id"` + ProductNo string `json:"product_no"` + Category Category `json:"category"` + Name string `json:"name"` + ShopName string `json:"shop_name"` + PriceAmount int64 `json:"price_amount"` + Currency string `json:"currency"` + CoverURL string `json:"cover_url"` + Summary string `json:"summary"` + ClaimedCount int64 `json:"claimed_count"` + IsRecommended bool `json:"is_recommended"` + AvailableActions []string `json:"available_actions"` + PaymentType string `json:"payment_type,omitempty"` + DetailText string `json:"detail_text,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type Category struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` +} + +type ProductPage struct { + Items []Product `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` +} + +func New(repository Repository) *Service { + return &Service{repository: repository} +} + +func (s *Service) Products(ctx context.Context, tenantID, categoryID int64, page, pageSize int) (*ProductPage, error) { + if categoryID < 0 { + return nil, ErrCategoryIDInvalid + } + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = defaultPageSize + } + if page < 1 { + return nil, ErrPageInvalid + } + if pageSize < 1 || pageSize > maxPageSize { + return nil, ErrPageSizeInvalid + } + rows, err := s.repository.ManualProducts(ctx, tenantID, categoryID, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]Product, 0, len(rows.Items)) + for _, row := range rows.Items { + items = append(items, productFromStore(row, false)) + } + return &ProductPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil +} + +func (s *Service) Product(ctx context.Context, tenantID, productID int64) (*Product, error) { + if productID <= 0 { + return nil, ErrProductIDInvalid + } + row, err := s.repository.ManualProduct(ctx, tenantID, productID) + if err != nil { + return nil, err + } + result := productFromStore(*row, true) + return &result, nil +} + +func productFromStore(row store.ManualProduct, detail bool) Product { + detailText := plainText(row.DetailHTML) + result := Product{ID: row.ID, ProductNo: row.ProductNo, + Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName}, + Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency, + CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes), + ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended, + AvailableActions: []string{"contact_support"}} + if detail { + result.PaymentType = row.PaymentType + result.DetailText = detailText + result.CreatedAt = row.CreatedAt + result.UpdatedAt = row.UpdatedAt + } + return result +} + +func plainText(raw string) string { + raw = scriptBlockPattern.ReplaceAllString(raw, " ") + raw = styleBlockPattern.ReplaceAllString(raw, " ") + withoutTags := htmlTagPattern.ReplaceAllString(raw, " ") + return strings.Join(strings.Fields(html.UnescapeString(withoutTags)), " ") +} + +func truncate(value string, limit int) string { + if utf8.RuneCountInString(value) <= limit { + return value + } + runes := []rune(value) + return string(runes[:limit]) + "…" +} + +func safeHTTPSURL(raw string) string { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return "" + } + return parsed.String() +} diff --git a/internal/mall/service_test.go b/internal/mall/service_test.go new file mode 100644 index 0000000..9c2cc22 --- /dev/null +++ b/internal/mall/service_test.go @@ -0,0 +1,61 @@ +package mall + +import ( + "context" + "errors" + "testing" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +type fakeRepository struct { + items []store.ManualProduct + detail *store.ManualProduct +} + +func (f *fakeRepository) ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) { + return &store.ManualProductPage{Items: f.items, Total: int64(len(f.items))}, nil +} + +func (f *fakeRepository) ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) { + return f.detail, nil +} + +func TestProductsMapsSafeCard(t *testing.T) { + service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03", + CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台", + PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png", + DetailHTML: "

标题

内容

", ClaimedCount: 2, IsRecommended: true}}}) + + result, err := service.Products(context.Background(), 0, 0, 1, 20) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" || + len(result.Items[0].AvailableActions) != 1 || result.Items[0].AvailableActions[0] != "contact_support" { + t.Fatalf("unexpected product: %#v", result.Items) + } +} + +func TestProductRejectsUnsafeCoverAndReturnsPlainText(t *testing.T) { + service := New(&fakeRepository{detail: &store.ManualProduct{ID: 4, Name: "商品", + CoverURL: "javascript:alert(1)", DetailHTML: "

安全说明

"}}) + + result, err := service.Product(context.Background(), 0, 4) + if err != nil { + t.Fatal(err) + } + if result.CoverURL != "" || result.DetailText != "安全说明" { + t.Fatalf("unexpected detail: %#v", result) + } +} + +func TestProductsValidatesPagination(t *testing.T) { + service := New(&fakeRepository{}) + if _, err := service.Products(context.Background(), 0, 0, -1, 20); !errors.Is(err, ErrPageInvalid) { + t.Fatalf("expected page error, got %v", err) + } + if _, err := service.Products(context.Background(), 0, -1, 1, 20); !errors.Is(err, ErrCategoryIDInvalid) { + t.Fatalf("expected category error, got %v", err) + } +} diff --git a/internal/managerconfig/client.go b/internal/managerconfig/client.go deleted file mode 100644 index 284914b..0000000 --- a/internal/managerconfig/client.go +++ /dev/null @@ -1,235 +0,0 @@ -package managerconfig - -import ( - "bytes" - "context" - "crypto/hmac" - cryptorand "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "git.118im.com/im_team/im_workbench/internal/config" -) - -const maxResponseBody = 1 << 20 - -type Client struct { - cfg config.ManagerConfig - http *http.Client -} - -func ResolveUIDKey(ctx context.Context, cfg config.ManagerConfig, fallback string) string { - key, err := New(cfg).UIDKey(ctx) - if err != nil || strings.TrimSpace(key) == "" { - return fallback - } - return key -} - -func ResolveAIConfig(ctx context.Context, manager config.ManagerConfig, fallback config.AIConfig) config.AIConfig { - values, err := New(manager).ThirdPartyConfig(ctx, "im_ai", - "provider", - "deepseek_base_url", - "deepseek_api_key", - "deepseek_default_model", - "deepseek_complex_model", - ) - if err != nil || len(values) == 0 { - return fallback - } - resolved := fallback - if value := strings.TrimSpace(values["provider"]); value != "" { - resolved.Provider = value - } - if value := strings.TrimSpace(values["deepseek_base_url"]); value != "" { - resolved.DeepSeek.BaseURL = value - } - if value := strings.TrimSpace(values["deepseek_api_key"]); value != "" { - resolved.DeepSeek.APIKey = value - } - if value := strings.TrimSpace(values["deepseek_default_model"]); value != "" { - resolved.DefaultModel = value - } - if value := strings.TrimSpace(values["deepseek_complex_model"]); value != "" { - resolved.ComplexModel = value - } - return resolved -} - -func New(cfg config.ManagerConfig) *Client { - timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond - if timeout <= 0 { - timeout = 3 * time.Second - } - return &Client{ - cfg: cfg, - http: &http.Client{Timeout: timeout}, - } -} - -func (c *Client) UIDKey(ctx context.Context) (string, error) { - baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/") - if baseURL == "" { - return "", fmt.Errorf("manager base url is empty") - } - if strings.TrimSpace(c.cfg.Secret) == "" { - return "", fmt.Errorf("manager secret is empty") - } - keys := cleanList(c.cfg.UIDKeyKeys) - if len(keys) == 0 { - keys = []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"} - } - group := strings.TrimSpace(c.cfg.UIDKeyGroup) - if group == "" { - group = "im" - } - values := url.Values{} - values.Set("keys", strings.Join(keys, ",")) - path := "/internal/im-config/third-party/" + url.PathEscape(group) + "?" + values.Encode() - req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil) - if err != nil { - return "", err - } - resp, err := c.http.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("manager status %d", resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) - if err != nil { - return "", err - } - valuesMap, err := parseValues(body) - if err != nil { - return "", err - } - for _, key := range keys { - if value := strings.TrimSpace(valuesMap[key]); value != "" { - return value, nil - } - } - return "", fmt.Errorf("uid key is empty") -} - -func (c *Client) ThirdPartyConfig(ctx context.Context, group string, keys ...string) (map[string]string, error) { - baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/") - if baseURL == "" { - return nil, fmt.Errorf("manager base url is empty") - } - if strings.TrimSpace(c.cfg.Secret) == "" { - return nil, fmt.Errorf("manager secret is empty") - } - values := url.Values{} - if keys := cleanList(keys); len(keys) > 0 { - values.Set("keys", strings.Join(keys, ",")) - } - path := "/internal/im-config/third-party/" + url.PathEscape(strings.TrimSpace(group)) - if encoded := values.Encode(); encoded != "" { - path += "?" + encoded - } - req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil) - if err != nil { - return nil, err - } - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("manager status %d", resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) - if err != nil { - return nil, err - } - return parseValues(body) -} - -func (c *Client) newRequest(ctx context.Context, method string, rawURL string, body []byte) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) - nonce, err := randomNonce() - if err != nil { - return nil, err - } - bodyHash := sha256.Sum256(body) - signingText := strings.Join([]string{ - method, - req.URL.RequestURI(), - hex.EncodeToString(bodyHash[:]), - timestamp, - nonce, - }, "\n") - mac := hmac.New(sha256.New, []byte(c.cfg.Secret)) - _, _ = mac.Write([]byte(signingText)) - serviceName := strings.TrimSpace(c.cfg.ServiceName) - if serviceName == "" { - serviceName = "im-workbench" - } - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Service-Name", serviceName) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Nonce", nonce) - req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) - return req, nil -} - -func parseValues(body []byte) (map[string]string, error) { - var envelope struct { - Code int `json:"code"` - Data json.RawMessage `json:"data"` - } - if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { - if envelope.Code != 0 { - return nil, fmt.Errorf("manager code %d", envelope.Code) - } - body = envelope.Data - } - var payload struct { - Values map[string]string `json:"values"` - } - if err := json.Unmarshal(body, &payload); err != nil { - return nil, err - } - if payload.Values == nil { - return map[string]string{}, nil - } - return payload.Values, nil -} - -func cleanList(values []string) []string { - items := make([]string, 0, len(values)) - seen := make(map[string]bool, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - continue - } - seen[value] = true - items = append(items, value) - } - return items -} - -func randomNonce() (string, error) { - var b [16]byte - if _, err := cryptorand.Read(b[:]); err != nil { - return "", err - } - return hex.EncodeToString(b[:]), nil -} diff --git a/internal/managerconfig/client_test.go b/internal/managerconfig/client_test.go deleted file mode 100644 index 0fd553f..0000000 --- a/internal/managerconfig/client_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package managerconfig - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.118im.com/im_team/im_workbench/internal/config" -) - -func TestResolveUIDKeyPrefersManager(t *testing.T) { - const secret = "test-secret" - const managerKey = "manager-key" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/internal/im-config/third-party/im" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if r.URL.Query().Get("keys") != "uid_encryption_key,UID_ENCRYPTION_KEY,uid_key" { - t.Fatalf("unexpected keys %s", r.URL.Query().Get("keys")) - } - if r.Header.Get("X-Service-Name") != "im-workbench" { - t.Fatalf("unexpected service %s", r.Header.Get("X-Service-Name")) - } - if !validSignature(t, r, secret) { - t.Fatal("invalid signature") - } - _, _ = w.Write([]byte(`{"code":0,"data":{"values":{"uid_encryption_key":"manager-key"}}}`)) - })) - defer server.Close() - - got := ResolveUIDKey(context.Background(), config.ManagerConfig{ - BaseURL: server.URL, - ServiceName: "im-workbench", - Secret: secret, - UIDKeyGroup: "im", - UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, - }, "fallback-key") - if got != managerKey { - t.Fatalf("ResolveUIDKey() = %q, want %q", got, managerKey) - } -} - -func TestResolveUIDKeyFallsBack(t *testing.T) { - got := ResolveUIDKey(context.Background(), config.ManagerConfig{}, "fallback-key") - if got != "fallback-key" { - t.Fatalf("ResolveUIDKey() = %q, want fallback", got) - } -} - -func TestParseValuesAcceptsUnwrappedResponse(t *testing.T) { - values, err := parseValues([]byte(`{"values":{"UID_ENCRYPTION_KEY":"key"}}`)) - if err != nil { - t.Fatal(err) - } - if values["UID_ENCRYPTION_KEY"] != "key" { - t.Fatalf("unexpected values %#v", values) - } -} - -func validSignature(t *testing.T, r *http.Request, secret string) bool { - t.Helper() - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatal(err) - } - bodyHash := sha256.Sum256(body) - signingText := strings.Join([]string{ - r.Method, - r.URL.RequestURI(), - hex.EncodeToString(bodyHash[:]), - r.Header.Get("X-Timestamp"), - r.Header.Get("X-Nonce"), - }, "\n") - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(signingText)) - return hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) -} diff --git a/internal/portal/errors.go b/internal/portal/errors.go new file mode 100644 index 0000000..17385ca --- /dev/null +++ b/internal/portal/errors.go @@ -0,0 +1,8 @@ +package portal + +import "errors" + +var ( + ErrPositionInvalid = errors.New("banner position invalid") + ErrContentKeyInvalid = errors.New("content key invalid") +) diff --git a/internal/portal/service.go b/internal/portal/service.go new file mode 100644 index 0000000..ebd8a73 --- /dev/null +++ b/internal/portal/service.go @@ -0,0 +1,249 @@ +package portal + +import ( + "context" + "encoding/json" + "net/url" + "regexp" + "strings" + + "git.118im.com/im_team/im_workbench/internal/accounts" + "git.118im.com/im_team/im_workbench/internal/store" +) + +var ( + positionPattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`) + contentKeyPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`) +) + +type Repository interface { + CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) + ActiveBanners(context.Context, int64, string) ([]store.Banner, error) + PublishedContent(context.Context, int64, string) (*store.Content, error) + ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) + ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) + MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) +} + +type Service struct { + repository Repository +} + +type Features struct { + TaskEnabled bool `json:"task_enabled"` + RechargeEnabled bool `json:"recharge_enabled"` + WithdrawalEnabled bool `json:"withdrawal_enabled"` + ManualMallEnabled bool `json:"manual_mall_enabled"` +} + +type Banner struct { + ID int64 `json:"id"` + ImageURL string `json:"image_url"` + AltText string `json:"alt_text"` + TargetType string `json:"target_type"` + TargetValue string `json:"target_value,omitempty"` +} + +type Notice struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + SourceType string `json:"source_type"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` +} + +type Home struct { + Profile HomeProfile `json:"profile"` + Wallet accounts.ProfileWallet `json:"wallet"` + Features Features `json:"features"` + Banners []Banner `json:"banners"` + Notices []Notice `json:"notices"` + TaskSummary store.TaskSummary `json:"task_summary"` +} + +type HomeProfile struct { + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + LevelName string `json:"level_name"` +} + +type Content struct { + ContentKey string `json:"content_key"` + ContentType string `json:"content_type"` + Title string `json:"title"` + Summary string `json:"summary"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` + CoverURL string `json:"cover_url"` + AssetIDs []int64 `json:"asset_ids"` + Version int `json:"version"` + PublishedAt string `json:"published_at"` +} + +type MemberLevel struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IconURL string `json:"icon_url"` + UpgradeAmount int64 `json:"upgrade_amount"` + GiftAmount int64 `json:"gift_amount"` + DailyOrderLimit int `json:"daily_order_limit"` + CommissionRate int `json:"commission_rate"` + DailyWithdrawLimit int `json:"daily_withdraw_limit"` + Rules json.RawMessage `json:"rules"` + Version int `json:"version"` +} + +func New(repository Repository) *Service { + return &Service{repository: repository} +} + +func (s *Service) Home(ctx context.Context, tenantID, memberID int64, profile *accounts.Profile) (*Home, error) { + config, err := s.repository.CurrentSiteConfig(ctx, tenantID) + if err != nil { + return nil, err + } + banners, err := s.Banners(ctx, tenantID, "home_top") + if err != nil { + return nil, err + } + noticeRows, err := s.repository.ActiveMemberNotices(ctx, tenantID, memberID, 5) + if err != nil { + return nil, err + } + summary, err := s.repository.MemberTaskSummary(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + notices := make([]Notice, 0, len(noticeRows)) + for _, row := range noticeRows { + notices = append(notices, Notice{ID: row.ID, Title: row.Title, Content: row.Content, + SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt}) + } + return &Home{ + Profile: HomeProfile{Username: profile.Username, AvatarURL: profile.AvatarURL, LevelName: profile.Level.Name}, + Wallet: profile.Wallet, Features: parseFeatures(config), Banners: banners, Notices: notices, + TaskSummary: *summary, + }, nil +} + +func (s *Service) Banners(ctx context.Context, tenantID int64, position string) ([]Banner, error) { + position = strings.ToLower(strings.TrimSpace(position)) + if !positionPattern.MatchString(position) { + return nil, ErrPositionInvalid + } + rows, err := s.repository.ActiveBanners(ctx, tenantID, position) + if err != nil { + return nil, err + } + items := make([]Banner, 0, len(rows)) + for _, row := range rows { + targetType, targetValue := bannerTarget(row.TargetType, row.TargetValue) + items = append(items, Banner{ID: row.ID, ImageURL: row.ImageURL, AltText: row.AltText, + TargetType: targetType, TargetValue: targetValue}) + } + return items, nil +} + +func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) { + key = strings.ToLower(strings.TrimSpace(key)) + if !contentKeyPattern.MatchString(key) { + return nil, ErrContentKeyInvalid + } + row, err := s.repository.PublishedContent(ctx, tenantID, key) + if err != nil { + return nil, err + } + assetIDs := make([]int64, 0) + if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" { + _ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs) + } + return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title, + Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL, + AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil +} + +func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) { + rows, err := s.repository.ActiveMemberLevels(ctx, tenantID) + if err != nil { + return nil, err + } + items := make([]MemberLevel, 0, len(rows)) + for _, row := range rows { + rules := row.RuleJSON + if len(rules) == 0 { + rules = json.RawMessage(`{}`) + } + items = append(items, MemberLevel{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL, + UpgradeAmount: row.UpgradeAmount, GiftAmount: row.GiftAmount, DailyOrderLimit: row.DailyOrderLimit, + CommissionRate: row.CommissionRate, DailyWithdrawLimit: row.DailyWithdrawLimit, + Rules: rules, Version: row.Version}) + } + return items, nil +} + +func parseFeatures(raw json.RawMessage) Features { + var config map[string]any + if json.Unmarshal(raw, &config) != nil { + return Features{} + } + return Features{ + TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"), + RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"), + WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), + ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), + } +} + +func findBool(config map[string]any, keys ...string) bool { + for _, key := range keys { + if value, ok := config[key]; ok { + return boolValue(value) + } + } + if nested, ok := config["features"].(map[string]any); ok { + for _, key := range keys { + if value, found := nested[key]; found { + return boolValue(value) + } + } + } + return false +} + +func boolValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case float64: + return typed == 1 + case string: + return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") + default: + return false + } +} + +func bannerTarget(kind int, value string) (string, string) { + value = strings.TrimSpace(value) + switch kind { + case 2: + if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") { + return "internal_route", value + } + case 3: + parsed, err := url.Parse(value) + if err == nil && parsed.Scheme == "https" && parsed.Host != "" { + return "external_url", value + } + } + return "none", "" +} + +func noticeSourceName(source int) string { + if source == 2 { + return "operational_simulation" + } + return "real" +} diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go new file mode 100644 index 0000000..5065be7 --- /dev/null +++ b/internal/portal/service_test.go @@ -0,0 +1,128 @@ +package portal + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "testing" + + "git.118im.com/im_team/im_workbench/internal/accounts" + "git.118im.com/im_team/im_workbench/internal/store" +) + +type fakeRepository struct { + config json.RawMessage + banners []store.Banner + content *store.Content + levels []store.MemberLevel + notices []store.MemberNotice + summary *store.TaskSummary +} + +func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { + return r.config, nil +} +func (r fakeRepository) ActiveBanners(context.Context, int64, string) ([]store.Banner, error) { + return r.banners, nil +} +func (r fakeRepository) PublishedContent(context.Context, int64, string) (*store.Content, error) { + if r.content == nil { + return nil, store.ErrResourceNotFound + } + return r.content, nil +} +func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) { + return r.levels, nil +} +func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) { + return r.notices, nil +} +func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) { + return r.summary, nil +} + +func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) { + features := parseFeatures(json.RawMessage(`{ + "features":{"task_enabled":true,"rechargeEnabled":1}, + "WITHDRAWAL":"on","SHOP_STATUS":"false" + }`)) + if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || features.ManualMallEnabled { + t.Fatalf("unexpected features: %+v", features) + } +} + +func TestBannersSanitizeTargets(t *testing.T) { + service := New(fakeRepository{banners: []store.Banner{ + {ID: 1, TargetType: 2, TargetValue: "/receive"}, + {ID: 2, TargetType: 2, TargetValue: "//evil.example"}, + {ID: 3, TargetType: 3, TargetValue: "https://example.com/path"}, + {ID: 4, TargetType: 3, TargetValue: "javascript:alert(1)"}, + }}) + items, err := service.Banners(context.Background(), 0, "home_top") + if err != nil { + t.Fatal(err) + } + wantTypes := []string{"internal_route", "none", "external_url", "none"} + for i, want := range wantTypes { + if items[i].TargetType != want { + t.Errorf("item %d target type = %q, want %q", i, items[i].TargetType, want) + } + } +} + +func TestBannersRejectInvalidPosition(t *testing.T) { + service := New(fakeRepository{}) + if _, err := service.Banners(context.Background(), 0, "../home"); !errors.Is(err, ErrPositionInvalid) { + t.Fatalf("error = %v, want %v", err, ErrPositionInvalid) + } +} + +func TestContentMapsAssets(t *testing.T) { + service := New(fakeRepository{content: &store.Content{ + ContentKey: "privacy_policy", Title: "Policy", AssetIDs: sql.NullString{String: `[1,2]`, Valid: true}, + }}) + content, err := service.Content(context.Background(), 0, "privacy_policy") + if err != nil { + t.Fatal(err) + } + if len(content.AssetIDs) != 2 || content.AssetIDs[0] != 1 || content.AssetIDs[1] != 2 { + t.Fatalf("unexpected asset IDs: %v", content.AssetIDs) + } +} + +func TestMemberLevelsAlwaysReturnJSONObjectRules(t *testing.T) { + service := New(fakeRepository{levels: []store.MemberLevel{{ID: 1, Code: "VIP1", Name: "Normal"}}}) + levels, err := service.MemberLevels(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + if string(levels[0].Rules) != `{}` { + t.Fatalf("rules = %s, want {}", levels[0].Rules) + } +} + +func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) { + service := New(fakeRepository{ + config: json.RawMessage(`{"features":{"task_enabled":true}}`), + summary: &store.TaskSummary{TodayCount: 2, TodayCommissionAmount: 150}, + }) + home, err := service.Home(context.Background(), 0, 7, &accounts.Profile{ + Username: "alice", Level: accounts.ProfileLevel{Name: "VIP1"}, + Wallet: accounts.ProfileWallet{Currency: "CNY"}, + }) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(home) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + for _, field := range []string{`"today_count":2`, `"today_commission_amount":150`, `"task_enabled":true`} { + if !strings.Contains(text, field) { + t.Errorf("home JSON %s does not contain %s", text, field) + } + } +} diff --git a/internal/store/accounts.go b/internal/store/accounts.go new file mode 100644 index 0000000..1dbc8cc --- /dev/null +++ b/internal/store/accounts.go @@ -0,0 +1,175 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "strconv" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" +) + +const ( + memberStatusActive = 1 + memberCategoryReal = 1 +) + +type AccountRegistration struct { + TenantID int64 + MemberNo string + Username string + MobileCipher string + MobileHash string + MobileMask string + PasswordHash string + ReferralCode string + InviteCode string +} + +type Account struct { + TenantID int64 `db:"tenant_id"` + MemberID int64 `db:"member_id"` + MemberNo string `db:"member_no"` + Username string `db:"username"` + MobileMask string `db:"mobile_mask"` + MobileHash string `db:"mobile_hash"` + PasswordHash string `db:"password_hash"` + InviteCode string `db:"invite_code"` + MemberStatus int `db:"member_status"` + MemberCategory int `db:"member_category"` +} + +func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration) (*Account, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + parentID, teamLevel, tree, err := referralParent(ctx, tx, input.TenantID, input.ReferralCode) + if err != nil { + return nil, err + } + levelID, err := defaultMemberLevel(ctx, tx, input.TenantID) + if err != nil { + return nil, err + } + + result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member + (tenant_id,member_no,username,mobile_cipher,mobile_hash,mobile_mask,password_hash, + level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch, + credit_score,is_test_account,created_by,updated_by) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`, + input.TenantID, input.MemberNo, input.Username, input.MobileCipher, input.MobileHash, + input.MobileMask, input.PasswordHash, levelID, parentID, teamLevel, tree, input.InviteCode) + if err != nil { + if isDuplicate(err) { + return nil, ErrAccountExists + } + return nil, err + } + memberID, err := result.LastInsertId() + if err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile + (tenant_id,member_id,created_by,updated_by) VALUES(?,?,0,0)`, input.TenantID, memberID); err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO hg_wallet_account + (tenant_id,member_id,currency,available_amount,frozen_amount,total_income,total_expense,account_status,version) + VALUES(?,?,'CNY',0,0,0,0,1,0)`, input.TenantID, memberID); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo, + Username: input.Username, MobileMask: input.MobileMask, MobileHash: input.MobileHash, + InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil +} + +func (s *Store) AccountByMobileHash(ctx context.Context, tenantID int64, mobileHash string) (*Account, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + return accountByMobileHash(ctx, s.db, tenantID, mobileHash) +} + +func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET last_login_at=?,updated_at=? + WHERE tenant_id=? AND id=? AND deleted_at IS NULL`, time.Now().UTC(), time.Now().UTC(), tenantID, memberID) + return err +} + +type queryer interface { + GetContext(context.Context, any, string, ...any) error +} + +func accountByMobileHash(ctx context.Context, db queryer, tenantID int64, mobileHash string) (*Account, error) { + var row Account + err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile_mask, + mobile_hash, password_hash, invite_code, member_status, risk_status AS member_category + FROM hg_app_member WHERE tenant_id=? AND mobile_hash=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobileHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvalidCredentials + } + if err != nil { + return nil, err + } + if row.MemberStatus != memberStatusActive { + return nil, ErrInvalidCredentials + } + return &row, nil +} + +func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) { + code = strings.TrimSpace(code) + if code == "" { + return 0, 1, "", nil + } + var parent struct { + ID int64 `db:"id"` + Level int `db:"level"` + Tree string `db:"tree"` + } + err := tx.GetContext(ctx, &parent, `SELECT id,level,tree FROM hg_app_member + WHERE tenant_id=? AND invite_code=? AND member_status=1 + AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, code) + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, "", ErrReferralInvalid + } + if err != nil { + return 0, 0, "", err + } + tree := strings.Trim(strings.TrimSpace(parent.Tree)+","+formatInt64(parent.ID), ",") + if len(tree) > 512 { + return 0, 0, "", ErrReferralInvalid + } + return parent.ID, parent.Level + 1, tree, nil +} + +func defaultMemberLevel(ctx context.Context, tx *sqlx.Tx, tenantID int64) (int64, error) { + var id int64 + err := tx.GetContext(ctx, &id, `SELECT id FROM hg_app_member_level + WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC LIMIT 1`, tenantID) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return id, err +} + +func isDuplicate(err error) bool { + var mysqlErr *mysql.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} + +func formatInt64(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/internal/store/admin.go b/internal/store/admin.go deleted file mode 100644 index 2193c45..0000000 --- a/internal/store/admin.go +++ /dev/null @@ -1,306 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "strings" - "time" -) - -type AdminWorkbenchModule struct { - ID int64 `db:"id"` - Code string `db:"code"` - Name string `db:"name"` - IconFileID string `db:"icon_file_id"` - Route string `db:"route"` - SortOrder int `db:"sort_order"` - UnreadKey string `db:"unread_key"` - Enabled int `db:"enabled"` - Permission string `db:"permission"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AdminWorkbenchModuleInput struct { - ID int64 - Code string - Name string - IconFileID string - Route string - SortOrder int - UnreadKey string - Enabled int - Permission string -} - -type AdminWorkbenchReorderItem struct { - Code string - SortOrder int -} - -type AdminMailboxConfig struct { - ID int64 `db:"id"` - Enabled int `db:"enabled"` - AllowAnonymous int `db:"allow_anonymous"` - RequiredFields string `db:"required_fields"` - MaxLength int `db:"max_length"` - AttachmentPolicy string `db:"attachment_policy"` - NoticeTitle string `db:"notice_title"` - NoticeContent string `db:"notice_content"` - NoticeUpdatedAt time.Time `db:"notice_updated_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AdminFeedbackConfig struct { - ID int64 `db:"id"` - Categories string `db:"categories"` - MaxLength int `db:"max_length"` - AttachmentPolicy string `db:"attachment_policy"` - AllowContact int `db:"allow_contact"` - DiagnosticsNotice string `db:"diagnostics_notice"` - UpdatedAt time.Time `db:"updated_at"` -} - -func (s *Store) AdminWorkbenchModules(ctx context.Context, page, perPage int, code, name string, enabled *int) ([]AdminWorkbenchModule, int, error) { - if page <= 0 { - page = 1 - } - if perPage <= 0 { - perPage = 20 - } - if perPage > 200 { - perPage = 200 - } - where := []string{"tenant_id=?"} - args := []any{s.tenant()} - if code = strings.TrimSpace(code); code != "" { - where = append(where, "code LIKE ?") - args = append(args, "%"+code+"%") - } - if name = strings.TrimSpace(name); name != "" { - where = append(where, "name LIKE ?") - args = append(args, "%"+name+"%") - } - if enabled != nil { - where = append(where, "enabled=?") - args = append(args, *enabled) - } - whereSQL := strings.Join(where, " AND ") - var total int - if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_workbench_apps WHERE "+whereSQL, args...); err != nil { - return nil, 0, err - } - queryArgs := append([]any{}, args...) - queryArgs = append(queryArgs, (page-1)*perPage, perPage) - var items []AdminWorkbenchModule - err := s.db.SelectContext(ctx, &items, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at - FROM im_workbench_apps WHERE `+whereSQL+` ORDER BY sort_order ASC,id ASC LIMIT ?,?`, queryArgs...) - return items, total, err -} - -func (s *Store) UpsertAdminWorkbenchModule(ctx context.Context, in AdminWorkbenchModuleInput) (*AdminWorkbenchModule, error) { - now := time.Now().UTC().Round(time.Millisecond) - in.Code = strings.TrimSpace(in.Code) - in.Name = strings.TrimSpace(in.Name) - in.IconFileID = strings.TrimSpace(in.IconFileID) - in.Route = strings.TrimSpace(in.Route) - in.UnreadKey = strings.TrimSpace(in.UnreadKey) - in.Permission = strings.TrimSpace(in.Permission) - if in.Permission == "" { - in.Permission = "enabled" - } - if in.ID > 0 { - res, err := s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET code=?,name=?,icon_file_id=?,route=?,sort_order=?,unread_key=?,enabled=?,permission=?,updated_at=? WHERE tenant_id=? AND id=?`, - in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, s.tenant(), in.ID) - if err != nil { - return nil, err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return nil, ErrNotFound - } - return s.adminWorkbenchModuleByID(ctx, in.ID) - } - res, err := s.db.ExecContext(ctx, `INSERT INTO im_workbench_apps(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, now) - if err != nil { - return nil, err - } - id, _ := res.LastInsertId() - return s.adminWorkbenchModuleByID(ctx, id) -} - -func (s *Store) DisableAdminWorkbenchModule(ctx context.Context, id int64, code string) error { - now := time.Now().UTC().Round(time.Millisecond) - var res sql.Result - var err error - if id > 0 { - res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND id=?`, now, s.tenant(), id) - } else { - res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND code=?`, now, s.tenant(), strings.TrimSpace(code)) - } - if err != nil { - return err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return ErrNotFound - } - return nil -} - -func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminWorkbenchReorderItem) error { - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - now := time.Now().UTC().Round(time.Millisecond) - for _, item := range items { - if _, err = tx.ExecContext(ctx, `UPDATE im_workbench_apps SET sort_order=?,updated_at=? WHERE tenant_id=? AND code=?`, item.SortOrder, now, s.tenant(), strings.TrimSpace(item.Code)); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *Store) AdminOrganizationDepartments(ctx context.Context, page, perPage int, parentID, name, state string) ([]OrganizationDepartment, int, error) { - page, perPage = normalizeAdminPage(page, perPage) - where := []string{"tenant_id=?"} - args := []any{s.tenant()} - if parentID = strings.TrimSpace(parentID); parentID != "" { - where = append(where, "parent_id=?") - args = append(args, parentID) - } - if name = strings.TrimSpace(name); name != "" { - where = append(where, "name LIKE ?") - args = append(args, "%"+name+"%") - } - if state = strings.TrimSpace(state); state != "" { - where = append(where, "state=?") - args = append(args, state) - } - whereSQL := strings.Join(where, " AND ") - var total int - if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_departments WHERE "+whereSQL, args...); err != nil { - return nil, 0, err - } - queryArgs := append([]any{}, args...) - queryArgs = append(queryArgs, (page-1)*perPage, perPage) - var items []OrganizationDepartment - err := s.db.SelectContext(ctx, &items, `SELECT d.department_id,d.name,d.parent_id, - (SELECT COUNT(*) FROM im_org_departments c WHERE c.tenant_id=d.tenant_id AND c.parent_id=d.department_id AND c.state='active') AS child_count, - (SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count, - d.version - FROM im_org_departments d WHERE `+whereSQL+` ORDER BY d.parent_id ASC,d.version DESC,d.department_id ASC LIMIT ?,?`, queryArgs...) - return items, total, err -} - -func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int, departmentID, keyword, state string, userID int64) ([]OrganizationMember, int, error) { - page, perPage = normalizeAdminPage(page, perPage) - where := []string{"tenant_id=?"} - args := []any{s.tenant()} - if departmentID = strings.TrimSpace(departmentID); departmentID != "" { - where = append(where, "department_id=?") - args = append(args, departmentID) - } - if keyword = strings.TrimSpace(keyword); keyword != "" { - where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ? OR email LIKE ?)") - like := "%" + keyword + "%" - args = append(args, like, like, like, like) - } - if state = strings.TrimSpace(state); state != "" { - where = append(where, "employment_state=?") - args = append(args, state) - } - if userID > 0 { - where = append(where, "user_id=?") - args = append(args, userID) - } - whereSQL := strings.Join(where, " AND ") - var total int - if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_members WHERE "+whereSQL, args...); err != nil { - return nil, 0, err - } - queryArgs := append([]any{}, args...) - queryArgs = append(queryArgs, (page-1)*perPage, perPage) - var items []OrganizationMember - err := s.db.SelectContext(ctx, &items, `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, - IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version - FROM im_org_members WHERE `+whereSQL+` ORDER BY department_id ASC,user_id ASC LIMIT ?,?`, queryArgs...) - return items, total, err -} - -func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) { - var cfg AdminMailboxConfig - if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { - if errorsIsNoRows(err) { - now := time.Now().UTC().Round(time.Millisecond) - return &AdminMailboxConfig{Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", NoticeUpdatedAt: now, UpdatedAt: now}, nil - } - return nil, err - } - return &cfg, nil -} - -func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error { - now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?) - ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`, - s.tenant(), cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now) - return err -} - -func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) { - var cfg AdminFeedbackConfig - if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { - if errorsIsNoRows(err) { - return &AdminFeedbackConfig{Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 2000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil - } - return nil, err - } - return &cfg, nil -} - -func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error { - now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?) - ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`, - s.tenant(), defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now) - return err -} - -func (s *Store) adminWorkbenchModuleByID(ctx context.Context, id int64) (*AdminWorkbenchModule, error) { - var item AdminWorkbenchModule - if err := s.db.GetContext(ctx, &item, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at FROM im_workbench_apps WHERE tenant_id=? AND id=?`, s.tenant(), id); err != nil { - if errorsIsNoRows(err) { - return nil, ErrNotFound - } - return nil, err - } - return &item, nil -} - -func errorsIsNoRows(err error) bool { - return err == sql.ErrNoRows -} - -func defaultString(value string, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback - } - return value -} - -func normalizeAdminPage(page, perPage int) (int, int) { - if page <= 0 { - page = 1 - } - if perPage <= 0 { - perPage = 20 - } - if perPage > 200 { - perPage = 200 - } - return page, perPage -} diff --git a/internal/store/ai.go b/internal/store/ai.go deleted file mode 100644 index ccc5091..0000000 --- a/internal/store/ai.go +++ /dev/null @@ -1,603 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "strconv" - "strings" - "time" -) - -type AICapability struct { - Feature string `db:"feature"` - Enabled bool `db:"enabled"` - Model string `db:"model"` - DailyLimit int `db:"daily_limit"` - ConcurrencyLimit int `db:"concurrency_limit"` - MaxInputChars int `db:"max_input_chars"` - MaxFileSizeMB int `db:"max_file_size_mb"` - ConfigJSON string `db:"config_json"` -} - -type AITask struct { - TaskID string `db:"task_id"` - TenantID int64 `db:"tenant_id"` - TenantCode string `db:"tenant_code"` - UserID string `db:"user_id"` - Feature string `db:"feature"` - Status string `db:"status"` - Progress int `db:"progress"` - SourceType string `db:"source_type"` - SourceRefID string `db:"source_ref_id"` - InputHash string `db:"input_hash"` - InputSnapshot sql.NullString `db:"input_snapshot"` - PromptVersion string `db:"prompt_version"` - Model string `db:"model"` - ResultRefID string `db:"result_ref_id"` - ResultJSON sql.NullString `db:"result_json"` - ErrorReason string `db:"error_reason"` - ErrorMessage string `db:"error_message"` - RetryCount int `db:"retry_count"` - StartedAt *time.Time `db:"started_at"` - FinishedAt *time.Time `db:"finished_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AIRecord struct { - RecordID string `db:"record_id"` - TenantCode string `db:"tenant_code"` - UserID string `db:"user_id"` - RecordType string `db:"record_type"` - Title string `db:"title"` - Summary string `db:"summary"` - Status string `db:"status"` - DetailID string `db:"detail_id"` - TaskID string `db:"task_id"` - FileName string `db:"file_name"` - FileType string `db:"file_type"` - FileSize int64 `db:"file_size"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - DeletedAt *time.Time `db:"deleted_at"` -} - -type AIDocument struct { - RecordID string `db:"record_id"` - UserID string `db:"user_id"` - DocumentType string `db:"document_type"` - Title string `db:"title"` - ContentMarkdown string `db:"content_markdown"` - ContentPlain string `db:"content_plain"` - SectionsJSON string `db:"sections_json"` - SourceExcerpt string `db:"source_excerpt"` - PromptVersion string `db:"prompt_version"` - Model string `db:"model"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AITableAnalysis struct { - RecordID string `db:"record_id"` - UserID string `db:"user_id"` - FileID string `db:"file_id"` - FileName string `db:"file_name"` - FileType string `db:"file_type"` - FileSize int64 `db:"file_size"` - Topic string `db:"topic"` - MetricsJSON string `db:"metrics_json"` - PreviewJSON string `db:"preview_json"` - AlertsJSON string `db:"alerts_json"` - Interpretation string `db:"interpretation"` - ExportFileID string `db:"export_file_id"` - PromptVersion string `db:"prompt_version"` - Model string `db:"model"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AIMeetingSummary struct { - SummaryID string `db:"summary_id"` - MeetingID string `db:"meeting_id"` - UserID string `db:"user_id"` - TaskID string `db:"task_id"` - ConclusionsJSON string `db:"conclusions_json"` - DecisionsJSON string `db:"decisions_json"` - ActionItemsJSON string `db:"action_items_json"` - SourceFileIDsJSON string `db:"source_file_ids_json"` - PromptVersion string `db:"prompt_version"` - Model string `db:"model"` - EditedBy string `db:"edited_by"` - EditedAt *time.Time `db:"edited_at"` - Version int `db:"version"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type AIUsageLog struct { - TenantCode string - UserID string - Feature string - TaskID string - Provider string - Model string - ProviderRequestID string - PromptTokens int - CompletionTokens int - TotalTokens int - LatencyMS int - Status string - ErrorReason string - InputHash string - OutputHash string -} - -type AIFileMetadata struct { - MIMEType string - FileSize int64 - FileETag string - Location string -} - -type CompletedFileUpload struct { - ID int64 - UserID int64 - MIMEType string - Size int64 - ETag string - Location string - CreatedAt time.Time -} - -func (s *Store) AICapabilities(ctx context.Context) ([]AICapability, error) { - var items []AICapability - err := s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit, - max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json - FROM wb_ai_capability_config WHERE tenant_id=? ORDER BY feature ASC`, s.tenant()) - if err != nil { - return nil, err - } - if len(items) > 0 { - return items, nil - } - err = s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit, - max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json - FROM wb_ai_capability_config WHERE tenant_id=0 ORDER BY feature ASC`) - return items, err -} - -func (s *Store) AIFileMetadata(ctx context.Context, fileID string) AIFileMetadata { - meta := s.fileUploadMetadata(ctx, strings.TrimSpace(fileID)) - return AIFileMetadata{ - MIMEType: meta.MIMEType, - FileSize: meta.FileSize, - FileETag: meta.FileETag, - Location: meta.Location, - } -} - -func (s *Store) CreateAITask(ctx context.Context, task AITask, result any) error { - now := time.Now().UTC().Round(time.Millisecond) - resultJSON, err := marshalNullable(result) - if err != nil { - return err - } - var inputSnapshot any - if task.InputSnapshot.Valid { - inputSnapshot = task.InputSnapshot.String - } - _, err = s.db.ExecContext(ctx, `INSERT INTO wb_ai_task - (task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,input_hash, - input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count, - started_at,finished_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?)`, - task.TaskID, s.tenant(), task.TenantCode, task.UserID, task.Feature, task.Status, task.Progress, - task.SourceType, task.SourceRefID, task.InputHash, inputSnapshot, task.PromptVersion, task.Model, task.ResultRefID, - resultJSON, task.ErrorReason, task.ErrorMessage, task.StartedAt, task.FinishedAt, now, now) - return err -} - -func (s *Store) ClaimQueuedAITask(ctx context.Context) (*AITask, error) { - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer tx.Rollback() - var task AITask - err = tx.GetContext(ctx, &task, `SELECT task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id, - input_hash,input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count, - started_at,finished_at,created_at,updated_at - FROM wb_ai_task - WHERE status='queued' AND feature IN ('smart_document','smart_table','meeting_summary') - ORDER BY created_at ASC,id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`) - if err == sql.ErrNoRows { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - now := time.Now().UTC().Round(time.Millisecond) - res, err := tx.ExecContext(ctx, `UPDATE wb_ai_task SET status='processing',progress=?,started_at=COALESCE(started_at,?),updated_at=? - WHERE tenant_id=? AND task_id=? AND status='queued'`, maxInt(task.Progress, 10), now, now, task.TenantID, task.TaskID) - if err != nil { - return nil, err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return nil, ErrNotFound - } - if err := tx.Commit(); err != nil { - return nil, err - } - task.Status = "processing" - task.Progress = maxInt(task.Progress, 10) - task.StartedAt = &now - task.UpdatedAt = now - return &task, nil -} - -func (s *Store) UpdateAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) error { - now := time.Now().UTC().Round(time.Millisecond) - resultJSON, err := marshalNullable(result) - if err != nil { - return err - } - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?, - error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=?`, - status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID) - if err != nil { - return err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return ErrNotFound - } - return nil -} - -func (s *Store) FinishAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) (bool, error) { - now := time.Now().UTC().Round(time.Millisecond) - resultJSON, err := marshalNullable(result) - if err != nil { - return false, err - } - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?, - error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=? AND status='processing'`, - status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID) - if err != nil { - return false, err - } - rows, err := res.RowsAffected() - if err != nil { - return false, err - } - return rows > 0, nil -} - -func (s *Store) CancelAITask(ctx context.Context, userID int64, taskID string) error { - now := time.Now().UTC().Round(time.Millisecond) - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status='canceled',progress=100,finished_at=?,updated_at=? - WHERE tenant_id=? AND user_id=? AND task_id=? AND status IN ('queued','processing')`, - now, now, s.tenant(), formatInt64(userID), taskID) - if err != nil { - return err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return ErrNotFound - } - return nil -} - -func (s *Store) AITask(ctx context.Context, userID int64, taskID string) (*AITask, error) { - var task AITask - err := s.db.GetContext(ctx, &task, `SELECT task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id, - input_hash,input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count, - started_at,finished_at,created_at,updated_at - FROM wb_ai_task WHERE tenant_id=? AND user_id=? AND task_id=?`, s.tenant(), formatInt64(userID), taskID) - if err == sql.ErrNoRows { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - return &task, nil -} - -func (s *Store) InsertAIUsageLog(ctx context.Context, log AIUsageLog) error { - now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_usage_log - (tenant_id,tenant_code,user_id,feature,task_id,provider,model,provider_request_id,prompt_tokens, - completion_tokens,total_tokens,latency_ms,status,error_reason,input_hash,output_hash,created_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - s.tenant(), log.TenantCode, log.UserID, log.Feature, log.TaskID, log.Provider, log.Model, log.ProviderRequestID, - log.PromptTokens, log.CompletionTokens, log.TotalTokens, log.LatencyMS, log.Status, log.ErrorReason, - log.InputHash, log.OutputHash, now) - return err -} - -func (s *Store) SaveAIDocument(ctx context.Context, tenantCode string, userID int64, record AIRecord, doc AIDocument) error { - now := time.Now().UTC().Round(time.Millisecond) - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record - (record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, - record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary, - record.Status, doc.RecordID, record.TaskID, now, now) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_document - (record_id,tenant_id,user_id,document_type,title,content_markdown,content_plain,sections_json,source_excerpt, - prompt_version,model,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, - doc.RecordID, s.tenant(), formatInt64(userID), doc.DocumentType, doc.Title, doc.ContentMarkdown, doc.ContentPlain, - doc.SectionsJSON, doc.SourceExcerpt, doc.PromptVersion, doc.Model, now, now) - if err != nil { - return err - } - return tx.Commit() -} - -func (s *Store) AIDocuments(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) { - return s.aiRecords(ctx, userID, "smart_document", queryText, cursor, limit) -} - -func (s *Store) AIDocument(ctx context.Context, userID int64, recordID string) (*AIDocument, error) { - var doc AIDocument - err := s.db.GetContext(ctx, &doc, `SELECT d.record_id,d.user_id,d.document_type,d.title,d.content_markdown, - COALESCE(d.content_plain,'') AS content_plain,COALESCE(CAST(d.sections_json AS CHAR), '[]') AS sections_json, - d.source_excerpt,d.prompt_version,d.model,d.created_at,d.updated_at - FROM wb_ai_document d - JOIN wb_ai_record r ON r.tenant_id=d.tenant_id AND r.record_id=d.record_id - WHERE d.tenant_id=? AND d.user_id=? AND d.record_id=? AND r.deleted_at IS NULL`, - s.tenant(), formatInt64(userID), recordID) - if err == sql.ErrNoRows { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - return &doc, nil -} - -func (s *Store) SaveAITableAnalysis(ctx context.Context, tenantCode string, userID int64, record AIRecord, analysis AITableAnalysis) error { - now := time.Now().UTC().Round(time.Millisecond) - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record - (record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, - record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary, - record.Status, analysis.RecordID, record.TaskID, now, now) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_table_analysis - (record_id,tenant_id,user_id,file_id,file_name,file_type,file_size,topic,metrics_json,preview_json,alerts_json, - interpretation,export_file_id,prompt_version,model,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - analysis.RecordID, s.tenant(), formatInt64(userID), analysis.FileID, analysis.FileName, analysis.FileType, analysis.FileSize, - analysis.Topic, analysis.MetricsJSON, analysis.PreviewJSON, analysis.AlertsJSON, analysis.Interpretation, analysis.ExportFileID, - analysis.PromptVersion, analysis.Model, now, now) - if err != nil { - return err - } - return tx.Commit() -} - -func (s *Store) SaveCompletedFileUpload(ctx context.Context, file CompletedFileUpload) error { - now := file.CreatedAt.UTC().Round(time.Millisecond) - if now.IsZero() { - now = time.Now().UTC().Round(time.Millisecond) - } - db := s.db - if s.core != nil && s.core.db != nil { - db = s.core.db - } - _, err := db.ExecContext(ctx, `INSERT INTO im_fileuploads - (tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) - VALUES(?,?,?,?,?,?,?,?,?,?)`, - s.tenant(), file.ID, now, now, formatInt64(file.UserID), 1, file.MIMEType, file.Size, file.ETag, file.Location) - return err -} - -func (s *Store) UpdateAITableAnalysisExportFileID(ctx context.Context, userID int64, recordID, fileID string) error { - now := time.Now().UTC().Round(time.Millisecond) - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_table_analysis SET export_file_id=?,updated_at=? - WHERE tenant_id=? AND user_id=? AND record_id=?`, - fileID, now, s.tenant(), formatInt64(userID), recordID) - if err != nil { - return err - } - affected, err := res.RowsAffected() - if err != nil { - return err - } - if affected == 0 { - return ErrNotFound - } - return nil -} - -func (s *Store) AITableAnalyses(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) { - where := `r.tenant_id=? AND r.user_id=? AND r.record_type='smart_table' AND r.deleted_at IS NULL` - args := []any{s.tenant(), formatInt64(userID)} - if queryText != "" { - where += ` AND (r.title LIKE ? OR r.summary LIKE ? OR a.file_name LIKE ?)` - like := "%" + queryText + "%" - args = append(args, like, like, like) - } - if cursor != "" { - where += ` AND r.record_id limit { - next = items[limit-1].RecordID - items = items[:limit] - } - return items, next, nil -} - -func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) { - return s.aiRecords(ctx, userID, recordType, queryText, cursor, limit) -} - -func (s *Store) DeleteAIRecord(ctx context.Context, userID int64, recordID string) error { - now := time.Now().UTC().Round(time.Millisecond) - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_record SET status='deleted',deleted_at=?,updated_at=? - WHERE tenant_id=? AND user_id=? AND record_id=? AND deleted_at IS NULL`, - now, now, s.tenant(), formatInt64(userID), recordID) - if err != nil { - return err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return ErrNotFound - } - return nil -} - -func (s *Store) AITableAnalysis(ctx context.Context, userID int64, recordID string) (*AITableAnalysis, error) { - var item AITableAnalysis - err := s.db.GetContext(ctx, &item, `SELECT a.record_id,a.user_id,a.file_id,a.file_name,a.file_type,a.file_size,a.topic, - COALESCE(CAST(a.metrics_json AS CHAR), '[]') AS metrics_json, - COALESCE(CAST(a.preview_json AS CHAR), '{}') AS preview_json, - COALESCE(CAST(a.alerts_json AS CHAR), '[]') AS alerts_json, - a.interpretation,a.export_file_id,a.prompt_version,a.model,a.created_at,a.updated_at - FROM wb_ai_table_analysis a - JOIN wb_ai_record r ON r.tenant_id=a.tenant_id AND r.record_id=a.record_id - WHERE a.tenant_id=? AND a.user_id=? AND a.record_id=? AND r.deleted_at IS NULL`, - s.tenant(), formatInt64(userID), recordID) - if err == sql.ErrNoRows { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - return &item, nil -} - -func (s *Store) UpsertAIMeetingSummary(ctx context.Context, tenantCode string, userID int64, summary AIMeetingSummary) error { - now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_meeting_summary - (summary_id,tenant_id,tenant_code,meeting_id,user_id,task_id,conclusions_json,decisions_json,action_items_json, - source_file_ids_json,prompt_version,model,version,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?) - ON DUPLICATE KEY UPDATE task_id=VALUES(task_id),conclusions_json=VALUES(conclusions_json), - decisions_json=VALUES(decisions_json),action_items_json=VALUES(action_items_json), - source_file_ids_json=VALUES(source_file_ids_json),prompt_version=VALUES(prompt_version),model=VALUES(model), - version=version+1,updated_at=VALUES(updated_at)`, - summary.SummaryID, s.tenant(), tenantCode, summary.MeetingID, formatInt64(userID), summary.TaskID, - summary.ConclusionsJSON, summary.DecisionsJSON, summary.ActionItemsJSON, summary.SourceFileIDsJSON, - summary.PromptVersion, summary.Model, now, now) - return err -} - -func (s *Store) AIMeetingSummary(ctx context.Context, userID int64, meetingID string) (*AIMeetingSummary, error) { - var item AIMeetingSummary - err := s.db.GetContext(ctx, &item, `SELECT summary_id,meeting_id,user_id,task_id, - COALESCE(CAST(conclusions_json AS CHAR), '[]') AS conclusions_json, - COALESCE(CAST(decisions_json AS CHAR), '[]') AS decisions_json, - COALESCE(CAST(action_items_json AS CHAR), '[]') AS action_items_json, - COALESCE(CAST(source_file_ids_json AS CHAR), '[]') AS source_file_ids_json, - prompt_version,model,edited_by,edited_at,version,created_at,updated_at - FROM wb_ai_meeting_summary WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID) - if err == sql.ErrNoRows { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - return &item, nil -} - -func (s *Store) UpdateAIMeetingSummary(ctx context.Context, userID int64, meetingID, summaryID, conclusionsJSON, decisionsJSON, actionItemsJSON string) (*AIMeetingSummary, error) { - meeting, err := s.Meeting(ctx, userID, meetingID) - if err != nil { - return nil, err - } - if meeting.OrganizerID != userID && meeting.RecorderID != userID { - return nil, ErrForbidden - } - now := time.Now().UTC().Round(time.Millisecond) - res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_meeting_summary SET conclusions_json=?,decisions_json=?,action_items_json=?, - edited_by=?,edited_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND summary_id=?`, - conclusionsJSON, decisionsJSON, actionItemsJSON, formatInt64(userID), now, now, s.tenant(), meetingID, summaryID) - if err != nil { - return nil, err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return nil, ErrNotFound - } - return s.AIMeetingSummary(ctx, userID, meetingID) -} - -func (s *Store) aiRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) { - where := `tenant_id=? AND user_id=? AND deleted_at IS NULL` - args := []any{s.tenant(), formatInt64(userID)} - if recordType != "" { - where += ` AND record_type=?` - args = append(args, recordType) - } - if queryText != "" { - where += ` AND (title LIKE ? OR summary LIKE ?)` - like := "%" + queryText + "%" - args = append(args, like, like) - } - if cursor != "" { - where += ` AND record_id limit { - next = items[limit-1].RecordID - items = items[:limit] - } - return items, next, nil -} - -func marshalNullable(value any) (any, error) { - if value == nil { - return nil, nil - } - data, err := json.Marshal(value) - if err != nil { - return nil, err - } - return string(data), nil -} - -func formatInt64(value int64) string { - return strconv.FormatInt(value, 10) -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/store/core_mysql.go b/internal/store/core_mysql.go deleted file mode 100644 index 69df8e9..0000000 --- a/internal/store/core_mysql.go +++ /dev/null @@ -1,116 +0,0 @@ -package store - -import ( - "context" - "strings" - - "git.118im.com/im_team/im_workbench/internal/config" - "github.com/jmoiron/sqlx" -) - -// CoreStore provides read-only access to shared IM data. -type CoreStore struct { - db *sqlx.DB -} - -func coreStoreOwnsDB(primaryDSN, coreDSN string) bool { - return strings.TrimSpace(primaryDSN) != strings.TrimSpace(coreDSN) -} - -func openCoreDSN(cfg config.MySQLConfig) (*CoreStore, error) { - db, err := openMySQLDB(cfg) - if err != nil { - return nil, err - } - return &CoreStore{db: db}, nil -} - -func (s *CoreStore) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} - -func (s *CoreStore) fileUploadMetadata(ctx context.Context, tenantID int64, fileID string) fileUploadMetadata { - var meta fileUploadMetadata - decodedID, storageName := parseUploadFileID(fileID) - locationCandidates := uploadLocationCandidates(fileID, storageName) - if decodedID <= 0 && len(locationCandidates) == 0 { - return meta - } - if decodedID > 0 { - err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name FROM im_fileuploads WHERE tenant_id=? AND id=? LIMIT 1`, tenantID, decodedID) - if err == nil { - return meta - } - } - for _, candidate := range locationCandidates { - err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name - FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1`, tenantID, candidate, "%/"+candidate) - if err == nil { - return meta - } - } - return meta -} - -func (s *CoreStore) populateUserSummaries(ctx context.Context, tenantID int64, ids []int64, result map[int64]UserSummary) error { - query, args, err := sqlx.In(`SELECT user_id,nickname AS display_name,IFNULL(avatar_file_id,'') AS avatar_file_id - FROM im_user_profiles WHERE tenant_id=? AND user_id IN (?) ORDER BY user_id ASC`, tenantID, ids) - if err != nil { - return err - } - var profileRows []struct { - UserID int64 `db:"user_id"` - DisplayName string `db:"display_name"` - AvatarFileID string `db:"avatar_file_id"` - } - if err := s.db.SelectContext(ctx, &profileRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { - return err - } - for _, row := range profileRows { - summary := result[row.UserID] - if summary.UserID == 0 { - summary.UserID = row.UserID - } - if summary.DisplayName == "" { - summary.DisplayName = strings.TrimSpace(row.DisplayName) - } - if summary.AvatarFileID == "" { - summary.AvatarFileID = row.AvatarFileID - } - result[row.UserID] = summary - } - - query, args, err = sqlx.In(`SELECT id AS user_id, - COALESCE( - NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.display_name')),''), - NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.fn')),''), - NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.nickname')),''), - NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.name')),''), - '' - ) AS display_name - FROM im_users WHERE tenant_id=? AND id IN (?)`, tenantID, ids) - if err != nil { - return err - } - var userRows []struct { - UserID int64 `db:"user_id"` - DisplayName string `db:"display_name"` - } - if err := s.db.SelectContext(ctx, &userRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { - return err - } - for _, row := range userRows { - summary := result[row.UserID] - if summary.UserID == 0 { - summary.UserID = row.UserID - } - if summary.DisplayName == "" { - summary.DisplayName = strings.TrimSpace(row.DisplayName) - } - result[row.UserID] = summary - } - return nil -} diff --git a/internal/store/core_mysql_test.go b/internal/store/core_mysql_test.go deleted file mode 100644 index ea0f7fe..0000000 --- a/internal/store/core_mysql_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package store - -import "testing" - -func TestCoreStoreOwnsDBOnlyForDifferentDSN(t *testing.T) { - tests := []struct { - name string - primaryDSN string - coreDSN string - wantOwnDB bool - }{ - {name: "same", primaryDSN: "root@tcp(db:3306)/meeting_im", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: false}, - {name: "same with whitespace", primaryDSN: " root@tcp(db:3306)/meeting_im ", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: false}, - {name: "different", primaryDSN: "root@tcp(db:3306)/workbench", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := coreStoreOwnsDB(tt.primaryDSN, tt.coreDSN); got != tt.wantOwnDB { - t.Fatalf("coreStoreOwnsDB() = %t, want %t", got, tt.wantOwnDB) - } - }) - } -} diff --git a/internal/store/errors.go b/internal/store/errors.go index d71c2ed..370d484 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -3,8 +3,32 @@ package store import "errors" var ( - ErrNotFound = errors.New("resource not found") - ErrForbidden = errors.New("forbidden") - ErrConflict = errors.New("conflict") - ErrPolicy = errors.New("policy violation") + ErrAccountExists = errors.New("account already exists") + ErrAccountNotFound = errors.New("account not found") + ErrAccountRestricted = errors.New("account restricted") + ErrAttachmentInvalid = errors.New("attachment invalid") + ErrConcurrentUpdate = errors.New("concurrent update") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrReferralInvalid = errors.New("referral code invalid") + ErrResourceNotFound = errors.New("resource not found") + ErrTaskHallNotFound = errors.New("task hall not found") + ErrTaskDisabled = errors.New("task feature disabled") + ErrOutsideBusinessHours = errors.New("outside business hours") + ErrInsufficientBalance = errors.New("insufficient balance") + ErrDailyLimitReached = errors.New("daily limit reached") + ErrLevelNotEligible = errors.New("member level not eligible") + ErrPendingOrderExists = errors.New("pending order exists") + ErrPaymentAccountExists = errors.New("payment account already exists") + ErrPaymentAccountInvalid = errors.New("payment account invalid") + ErrWithdrawalDisabled = errors.New("withdrawal disabled") + ErrWithdrawalChannel = errors.New("withdrawal channel unavailable") + ErrWithdrawalAmount = errors.New("withdrawal amount invalid") + ErrWithdrawalLimit = errors.New("withdrawal daily limit reached") + ErrWithdrawalNotFound = errors.New("withdrawal order not found") + ErrIdempotencyConflict = errors.New("idempotency key reused with different request") + ErrNoTaskAvailable = errors.New("no task available") + ErrOrderNotFound = errors.New("task order not found") + ErrOrderState = errors.New("task order state invalid") + ErrOrderExpired = errors.New("task order expired") + ErrEvidenceInvalid = errors.New("task evidence invalid") ) diff --git a/internal/store/manual_products.go b/internal/store/manual_products.go new file mode 100644 index 0000000..0653e9c --- /dev/null +++ b/internal/store/manual_products.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "database/sql" + "errors" +) + +type ManualProduct struct { + ID int64 `db:"id"` + ProductNo string `db:"product_no"` + CategoryID int64 `db:"category_id"` + CategoryCode string `db:"category_code"` + CategoryName string `db:"category_name"` + PaymentType string `db:"payment_type"` + Name string `db:"name"` + ShopName string `db:"shop_name"` + PriceAmount int64 `db:"price_amount"` + Currency string `db:"currency"` + CoverURL string `db:"cover_url"` + DetailHTML string `db:"detail_html"` + IsRecommended bool `db:"is_recommended"` + ClaimedCount int64 `db:"claimed_count"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +type ManualProductPage struct { + Items []ManualProduct + Total int64 +} + +func (s *Store) ManualProducts(ctx context.Context, tenantID, categoryID int64, offset, limit int) (*ManualProductPage, error) { + where := ` WHERE p.tenant_id=? AND p.product_type=2 AND p.publish_status=3 + AND p.deleted_at IS NULL AND c.status=1 AND c.deleted_at IS NULL + AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3)) + AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3))` + args := []any{tenantID} + if categoryID > 0 { + where += ` AND p.category_id=?` + args = append(args, categoryID) + } + var total int64 + if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_product p + JOIN hg_product_category c ON c.tenant_id=p.tenant_id AND c.id=p.category_id`+where, args...); err != nil { + return nil, err + } + args = append(args, limit, offset) + items := make([]ManualProduct, 0) + err := s.db.SelectContext(ctx, &items, manualProductSelectSQL()+where+ + ` ORDER BY p.is_recommended ASC,p.sort ASC,p.id ASC LIMIT ? OFFSET ?`, args...) + return &ManualProductPage{Items: items, Total: total}, err +} + +func (s *Store) ManualProduct(ctx context.Context, tenantID, productID int64) (*ManualProduct, error) { + var row ManualProduct + err := s.db.GetContext(ctx, &row, manualProductSelectSQL()+` WHERE p.tenant_id=? AND p.id=? + AND p.product_type=2 AND p.publish_status=3 AND p.deleted_at IS NULL + AND c.status=1 AND c.deleted_at IS NULL + AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3)) + AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3)) LIMIT 1`, tenantID, productID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrResourceNotFound + } + return &row, err +} + +func manualProductSelectSQL() string { + return `SELECT p.id,p.product_no,p.category_id,c.category_code,c.name AS category_name, + p.payment_type,p.name,p.shop_name,p.price_amount,p.currency,p.cover_url, + COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended, + (SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id + AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count, + DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, + DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at + FROM hg_product p JOIN hg_product_category c + ON c.tenant_id=p.tenant_id AND c.id=p.category_id` +} diff --git a/internal/store/meeting_note_file_test.go b/internal/store/meeting_note_file_test.go deleted file mode 100644 index 29e03ff..0000000 --- a/internal/store/meeting_note_file_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package store - -import "testing" - -func TestNormalizeMeetingNoteFileMetadataUsesUploadMetadata(t *testing.T) { - gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{ - FileID: "lxzvtUzicEo", - }, fileUploadMetadata{ - MIMEType: "application/pdf", - FileSize: 12345, - FileETag: "etag-1", - Location: "uploads/wontuse", - OriginalName: "会议纪要.pdf", - }, "lxzvtUzicEo") - if gotName != "会议纪要.pdf" { - t.Fatalf("name=%q, want original upload name", gotName) - } - if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" { - t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag) - } -} - -func TestNormalizeMeetingNoteFileMetadataFallsBackToLocation(t *testing.T) { - gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{ - FileID: "lxzvtUzicEo", - }, fileUploadMetadata{ - MIMEType: "application/pdf", - FileSize: 12345, - FileETag: "etag-1", - Location: "uploads/lxzvtUzicEo.pdf", - }, "lxzvtUzicEo") - if gotName != "lxzvtUzicEo.pdf" { - t.Fatalf("name=%q, want location file name", gotName) - } - if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" { - t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag) - } -} - -func TestNormalizeMeetingNoteFileMetadataKeepsRequestMetadata(t *testing.T) { - size := int64(88) - gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{ - FileID: "file_1", - FileName: "../会议纪要.docx", - MIMEType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - FileSize: &size, - FileETag: "request-etag", - }, fileUploadMetadata{ - MIMEType: "application/pdf", - FileSize: 12345, - FileETag: "upload-etag", - Location: "uploads/file_1.pdf", - }, "file_1") - if gotName != "会议纪要.docx" { - t.Fatalf("name=%q, want original file name", gotName) - } - if gotMime != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || gotSize != 88 || gotETag != "request-etag" { - t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag) - } -} - -func TestParseUploadFileIDAcceptsExtension(t *testing.T) { - decodedID, storageName := parseUploadFileID("lxzvtUzicEo.pdf") - if decodedID <= 0 { - t.Fatalf("decodedID=%d, want positive", decodedID) - } - if storageName == "" { - t.Fatal("storageName is empty") - } -} - -func TestParseUploadFileIDAcceptsOSSURL(t *testing.T) { - decodedID, storageName := parseUploadFileID("https://oss.example.com/files/lxzvtUzicEo.pdf?Expires=1") - if decodedID <= 0 { - t.Fatalf("decodedID=%d, want positive", decodedID) - } - if storageName == "" { - t.Fatal("storageName is empty") - } -} - -func TestParseUploadFileIDAcceptsObjectKey(t *testing.T) { - decodedID, storageName := parseUploadFileID("tenants/1/2026/09/s4oo7nkm4jyeu") - if decodedID != 0 { - t.Fatalf("decodedID=%d, want 0 for object key", decodedID) - } - if storageName != "s4oo7nkm4jyeu" { - t.Fatalf("storageName=%q, want s4oo7nkm4jyeu", storageName) - } - candidates := uploadLocationCandidates("tenants/1/2026/09/s4oo7nkm4jyeu", storageName) - if len(candidates) < 2 || candidates[0] != "s4oo7nkm4jyeu" || candidates[1] != "tenants/1/2026/09/s4oo7nkm4jyeu" { - t.Fatalf("candidates=%#v", candidates) - } -} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index fa77b5c..0ff02e9 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -2,90 +2,17 @@ package store import ( "context" - "database/sql" - "encoding/base32" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "mime" - "net/url" - "path/filepath" - "strings" - "sync" "time" "git.118im.com/im_team/im_workbench/internal/config" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/xtea" ) type Store struct { - db *sqlx.DB - dsn string - core *CoreStore - tenantID int64 - tenantStores map[int64]*Store - tenantStoreMu sync.Mutex + db *sqlx.DB } -const defaultTenantID int64 = 1 - -var ( - uidCipherMu sync.RWMutex - uidCipher *xtea.Cipher -) - -func init() { - if err := SetUIDKey("dkXxWLwqUNRfYmdXRzOkZw=="); err != nil { - panic(err) - } -} - -func SetUIDKey(encoded string) error { - key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return fmt.Errorf("invalid uid key: %w", err) - } - cipher, err := xtea.NewCipher(key) - if err != nil { - return fmt.Errorf("invalid uid key: %w", err) - } - uidCipherMu.Lock() - uidCipher = cipher - uidCipherMu.Unlock() - return nil -} - -func Open(cfg, coreCfg config.MySQLConfig) (*Store, error) { - st, err := openDSN(cfg) - if err != nil { - return nil, err - } - if coreStoreOwnsDB(cfg.DSN, coreCfg.DSN) { - core, err := openCoreDSN(coreCfg) - if err != nil { - _ = st.Close() - return nil, err - } - st.core = core - } else { - st.core = &CoreStore{db: st.db} - } - st.tenantStores = make(map[int64]*Store) - return st, nil -} - -func openDSN(cfg config.MySQLConfig) (*Store, error) { - db, err := openMySQLDB(cfg) - if err != nil { - return nil, err - } - return &Store{db: db, dsn: cfg.DSN}, nil -} - -func openMySQLDB(cfg config.MySQLConfig) (*sqlx.DB, error) { +func Open(cfg config.MySQLConfig) (*Store, error) { db, err := sqlx.Open("mysql", cfg.DSN) if err != nil { return nil, err @@ -99,1265 +26,12 @@ func openMySQLDB(cfg config.MySQLConfig) (*sqlx.DB, error) { _ = db.Close() return nil, err } - return db, nil -} - -func (s *Store) tenant() int64 { - if s != nil && s.tenantID > 0 { - return s.tenantID - } - return defaultTenantID + return &Store{db: db}, nil } func (s *Store) Close() error { if s == nil || s.db == nil { return nil } - s.tenantStoreMu.Lock() - for id, st := range s.tenantStores { - if st != nil && st != s && st.db != s.db { - _ = st.Close() - } - delete(s.tenantStores, id) - } - s.tenantStoreMu.Unlock() - if s.core != nil && s.core.db != s.db { - _ = s.core.Close() - } return s.db.Close() } - -// ForTenant returns a tenant-scoped Store over the configured shared database. -func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) { - if s == nil { - return nil, errors.New("store is nil") - } - _ = ctx - _ = tenantCode - cacheID := tenantID - if cacheID <= 0 { - cacheID = defaultTenantID - } - if cacheID == s.tenant() { - return s, nil - } - s.tenantStoreMu.Lock() - if st := s.tenantStores[cacheID]; st != nil { - s.tenantStoreMu.Unlock() - return st, nil - } - st := &Store{db: s.db, dsn: s.dsn, core: s.core, tenantID: cacheID} - s.tenantStores[cacheID] = st - s.tenantStoreMu.Unlock() - return st, nil -} - -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 tenant_id=? AND enabled=1 ORDER BY sort_order ASC,id ASC`, s.tenant()) - if err != nil { - if isMissingTable(err) { - return []WorkbenchModule{}, 1, 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) { - if s.core == nil || s.core.db == nil { - return 0, nil - } - var count int - query := fmt.Sprintf(`SELECT COUNT(*) FROM %s - WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE 'meeting.%%'`, tenantSystemNotificationsTable(s.tenant())) - err := s.core.db.GetContext(ctx, &count, query, s.tenant(), userID) - if isMissingTable(err) { - return 0, nil - } - return count, err -} - -func tenantSystemNotificationsTable(tenantID int64) string { - return fmt.Sprintf("im_%03d_system_notifications", tenantID) -} - -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.tenant_id=d.tenant_id AND c.parent_id=d.department_id) AS child_count, - (SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count, - d.version - FROM im_org_departments d WHERE d.tenant_id=? AND d.parent_id=?` - args := []any{s.tenant(), 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,employment_state AS status,contactable,version - FROM im_org_members WHERE tenant_id=? AND department_id=? AND employment_state='active'` - args := []any{s.tenant(), 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{"tenant_id=?", "employment_state='active'"} - args := []any{s.tenant()} - 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,employment_state AS 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,NULL AS appeal_deadline - FROM im_attendance_rules WHERE tenant_id=? AND state='active' ORDER BY id ASC LIMIT 1`, s.tenant()); 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 tenant_id=? AND rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, s.tenant(), 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{"tenant_id=?"} - args := []any{s.tenant()} - if opts.UserID > 0 { - where = append(where, "user_id=?") - args = append(args, opts.UserID) - } else if opts.Scope != "managed" { - where = append(where, "user_id=?") - args = append(args, userID) - } - if opts.Date != "" { - where = append(where, "record_date=?") - args = append(args, opts.Date) - } - if opts.From != "" { - where = append(where, "record_date>=?") - args = append(args, opts.From) - } - if opts.To != "" { - where = append(where, "record_date<=?") - args = append(args, opts.To) - } - if opts.Status != "" { - where = append(where, "status=?") - args = append(args, opts.Status) - } - if opts.Cursor != "" { - where = append(where, "id opts.Limit { - next = records[opts.Limit-1].RecordID - records = records[:opts.Limit] - } - for i := range records { - records[i].Checkins, _ = s.attendanceCheckins(ctx, records[i].RecordID) - } - stats := &AttendanceStats{ExpectedCount: len(records), CheckedCount: len(records)} - return records, next, stats, nil -} - -type AttendanceListOptions struct { - Scope string - From string - To string - Date string - Status string - Cursor string - Limit int - UserID int64 -} - -func (s *Store) CreateAttendanceCheckin(ctx context.Context, userID int64, checkinID string, req AttendanceCheckinRequest) (*AttendanceRecord, *AttendanceCheckin, error) { - now := time.Now().UTC().Round(time.Millisecond) - recordID := fmt.Sprintf("att_%d_%s_%s", userID, req.Date.Format("20060102"), req.ShiftID) - result := "normal" - status := "checked_in" - if req.Action == "check_out" { - status = "normal" - } - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return nil, nil, err - } - defer tx.Rollback() - _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(tenant_id,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)`, - s.tenant(), 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(tenant_id,checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - s.tenant(), 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{"tenant_id=?", "user_id=?"} - args := []any{s.tenant(), userID} - if status != "" { - where = append(where, "status=?") - args = append(args, status) - } - if cursor != "" { - where = append(where, "id limit { - next = items[limit-1].AppealID - items = items[:limit] - } - return items, next, nil -} - -func (s *Store) CreateAttendanceAppeal(ctx context.Context, userID int64, appeal AttendanceAppeal, idempotencyKey string) (*AttendanceAppeal, error) { - now := time.Now().UTC().Round(time.Millisecond) - if appeal.Status == "" { - appeal.Status = "pending" - } - _, err := s.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(tenant_id,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,?,?,?,?)`, - s.tenant(), 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 tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), 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 tenant_id=? AND (organizer_id=? OR EXISTS(SELECT 1 FROM im_meeting_participants p WHERE p.tenant_id=im_meetings.tenant_id AND p.meeting_id=im_meetings.meeting_id AND p.user_id=?))` - args := []any{s.tenant(), 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) - _ = s.hydrateMeetingUsers(ctx, &items[i]) - } - 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 tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - if isMissingTable(err) { - return nil, ErrNotFound - } - return nil, err - } - meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID) - _ = s.hydrateMeetingUsers(ctx, &meeting) - 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(tenant_id,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,?,?)`, s.tenant(), 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 tenant_id=? AND organizer_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), 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(tenant_id,meeting_id,user_id,response) VALUES(?,?,?,?)`, s.tenant(), 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, s.tenant(), meetingID) - if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND 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 tenant_id=? AND meeting_id=? AND organizer_id=? AND state='scheduled'`, now, now, s.tenant(), 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 tenant_id=? AND meeting_id=? AND user_id=?`, response, now, s.tenant(), 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 tenant_id=? AND meeting_id=? AND status='active' ORDER BY id DESC`, s.tenant(), meetingID); err != nil { - return nil, err - } - if err := s.hydrateMeetingNoteFilesUploaders(ctx, files); err != nil { - return nil, err - } - return files, nil -} - -func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, 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) - fileID := strings.TrimSpace(input.FileID) - meta := s.fileUploadMetadata(ctx, fileID) - fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) - _, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(tenant_id,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,?,?,?)`, s.tenant(), 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 tenant_id=? AND meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), meetingID, userID, idempotencyKey); getErr != nil { - return nil, getErr - } - if getErr := s.hydrateMeetingNoteFileUploader(ctx, &existing); 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 tenant_id=? AND note_file_id=? LIMIT 1`, s.tenant(), noteFileID); err != nil { - return nil, err - } - if err := s.hydrateMeetingNoteFileUploader(ctx, &created); err != nil { - return nil, err - } - return &created, nil -} - -func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, 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 - } - fileID := strings.TrimSpace(input.FileID) - meta := s.fileUploadMetadata(ctx, fileID) - fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) - 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 tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`, - fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), meetingID, noteFileID, version) - if err != nil { - return nil, err - } - if rows, _ := res.RowsAffected(); rows == 0 { - return nil, ErrConflict - } - return s.meetingNoteFile(ctx, meetingID, noteFileID) -} - -type fileUploadMetadata struct { - MIMEType string `db:"mimetype"` - FileSize int64 `db:"size"` - FileETag string `db:"etag"` - Location string `db:"location"` - OriginalName string `db:"original_name"` -} - -func (s *Store) fileUploadMetadata(ctx context.Context, fileID string) fileUploadMetadata { - if s.core == nil { - return fileUploadMetadata{} - } - return s.core.fileUploadMetadata(ctx, s.tenant(), fileID) -} - -func parseUploadFileID(fileID string) (int64, string) { - name := uploadFileName(fileID) - if dot := strings.LastIndexByte(name, '.'); dot > 0 { - ext := name[dot+1:] - if len(ext) == 0 || len(ext) > 16 { - return 0, "" - } - for _, r := range ext { - if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') { - return 0, "" - } - } - name = name[:dot] - } - decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(name) - if err != nil || len(decoded) < 8 { - if isStorageFileName(name) { - return 0, name - } - return 0, "" - } - storageData := make([]byte, 8) - binary.LittleEndian.PutUint64(storageData, binary.LittleEndian.Uint64(decoded[:8])) - storageName := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(storageData)) - - plain := make([]byte, 8) - uidCipherMu.RLock() - cipher := uidCipher - uidCipherMu.RUnlock() - cipher.Decrypt(plain, decoded[:8]) - return int64(binary.LittleEndian.Uint64(plain)), storageName -} - -func uploadFileName(fileID string) string { - name := strings.TrimSpace(fileID) - if parsed, err := url.Parse(name); err == nil && parsed.Path != "" { - name = parsed.Path - } - if q := strings.IndexAny(name, "?#"); q >= 0 { - name = name[:q] - } - name = strings.TrimRight(strings.ReplaceAll(name, "\\", "/"), "/") - if slash := strings.LastIndexByte(name, '/'); slash >= 0 { - name = name[slash+1:] - } - return strings.TrimSpace(name) -} - -func uploadLocationCandidates(fileID, storageName string) []string { - seen := map[string]bool{} - var candidates []string - add := func(value string) { - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "" || seen[value] { - return - } - seen[value] = true - candidates = append(candidates, value) - } - add(storageName) - raw := strings.TrimSpace(fileID) - if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" { - add(parsed.Path) - } - add(raw) - return candidates -} - -func isStorageFileName(name string) bool { - if len(name) != 13 { - return false - } - for _, r := range name { - if (r < 'a' || r > 'z') && (r < '2' || r > '7') { - return false - } - } - return true -} - -func normalizeMeetingNoteFileMetadata(input MeetingNoteFileInput, meta fileUploadMetadata, fileID string) (string, string, int64, string) { - mimeType := firstNonEmpty(input.MIMEType, meta.MIMEType) - fileSize := int64(0) - if input.FileSize != nil && *input.FileSize > 0 { - fileSize = *input.FileSize - } else if meta.FileSize > 0 { - fileSize = meta.FileSize - } - fileETag := firstNonEmpty(input.FileETag, meta.FileETag) - fileName := normalizeMeetingNoteFileName(firstNonEmpty(input.FileName, meta.OriginalName, fileNameFromLocation(meta.Location), fallbackFileName(fileID, mimeType)), fileID) - return fileName, strings.TrimSpace(mimeType), fileSize, strings.TrimSpace(fileETag) -} - -func normalizeMeetingNoteFileName(fileName, fileID string) string { - fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/")) - fileName = filepath.Base(fileName) - if fileName == "." || fileName == "/" || fileName == "" { - fileName = strings.TrimSpace(fileID) - } - if len([]rune(fileName)) <= 255 { - return fileName - } - runes := []rune(fileName) - ext := filepath.Ext(fileName) - if ext == "" || len([]rune(ext)) >= 255 { - return string(runes[:255]) - } - extRunes := []rune(ext) - return string(runes[:255-len(extRunes)]) + ext -} - -func fileNameFromLocation(location string) string { - name := strings.TrimSpace(strings.ReplaceAll(location, "\\", "/")) - name = filepath.Base(name) - if name == "." || name == "/" { - return "" - } - if filepath.Ext(name) == "" { - return "" - } - return name -} - -func fallbackFileName(fileID, mimeType string) string { - fileID = strings.TrimSpace(fileID) - if filepath.Ext(fileID) != "" || strings.TrimSpace(mimeType) == "" { - return fileID - } - exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType)) - if len(exts) == 0 { - return fileID - } - return fileID + strings.ToLower(exts[0]) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -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 tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`, - idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), 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, reportDate, 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 tenant_id=? AND author_id=?` - args := []any{s.tenant(), userID} - if reportDate != "" { - query += ` AND report_date=?` - args = append(args, reportDate) - } - 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(tenant_id,report_id,author_id,report_date,content,attachments,status,read_state,version,created_at,updated_at) - VALUES(?,?,?,?,'','[]','draft','unread',1,?,?)`, s.tenant(), 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 tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), 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 tenant_id=? AND report_id=? AND author_id=? AND version=?`, - content, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), 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 tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), 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) DailyReportStats(ctx context.Context, date time.Time, departmentID string) (*ReportStats, error) { - stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} - memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) - memberWhere = append([]string{"tenant_id=?"}, memberWhere...) - memberArgs = append([]any{s.tenant()}, memberArgs...) - if err := s.db.GetContext(ctx, &stats.ExpectedCount, - `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { - if isMissingTable(err) { - return stats, nil - } - return nil, err - } - args := append([]any{s.tenant(), date.Format("2006-01-02")}, memberArgs...) - if err := s.db.GetContext(ctx, &stats.SubmittedCount, - `SELECT COUNT(DISTINCT r.author_id) - FROM im_daily_reports AS r - INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id - WHERE r.tenant_id=? AND r.report_date=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { - if isMissingTable(err) { - return stats, nil - } - return nil, err - } - stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount - if stats.MissingCount < 0 { - stats.MissingCount = 0 - } - return stats, nil -} - -func (s *Store) MonthlyReports(ctx context.Context, userID int64, fromMonth, toMonth, 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 tenant_id=? AND author_id=?` - args := []any{s.tenant(), userID} - if fromMonth != "" { - query += ` AND report_month>=?` - args = append(args, fromMonth) - } - if toMonth != "" { - query += ` AND report_month<=?` - args = append(args, toMonth) - } - 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(tenant_id,report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,created_at,updated_at) - VALUES(?,?,?,?,'','','[]','draft','unread',1,?,?)`, s.tenant(), 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 tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), 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 tenant_id=? AND report_id=? AND author_id=? AND version=?`, - summary, nextPlan, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), 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 tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), 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 tenant_id=? AND report_id=? AND author_id=? AND version=?`, - time.Now().UTC().Round(time.Millisecond), s.tenant(), 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) MonthlyReportStats(ctx context.Context, month, departmentID string) (*ReportStats, error) { - stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} - memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) - memberWhere = append([]string{"tenant_id=?"}, memberWhere...) - memberArgs = append([]any{s.tenant()}, memberArgs...) - if err := s.db.GetContext(ctx, &stats.ExpectedCount, - `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { - if isMissingTable(err) { - return stats, nil - } - return nil, err - } - args := append([]any{s.tenant(), strings.TrimSpace(month)}, memberArgs...) - if err := s.db.GetContext(ctx, &stats.SubmittedCount, - `SELECT COUNT(DISTINCT r.author_id) - FROM im_monthly_reports AS r - INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id - WHERE r.tenant_id=? AND r.report_month=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { - if isMissingTable(err) { - return stats, nil - } - return nil, err - } - stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount - if stats.MissingCount < 0 { - stats.MissingCount = 0 - } - return stats, nil -} - -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,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); 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) { - cfg, err := s.MailboxConfig(ctx) - if err != nil { - return nil, err - } - submission.Subject = strings.TrimSpace(submission.Subject) - submission.Content = strings.TrimSpace(submission.Content) - if !cfg.Enabled || (submission.Anonymous && !cfg.AllowAnonymous) { - return nil, ErrPolicy - } - maxLength := cfg.MaxLength - if maxLength <= 0 { - maxLength = 5000 - } - if len([]rune(submission.Subject)) > 200 || len([]rune(submission.Content)) > maxLength { - return nil, ErrPolicy - } - now := time.Now().UTC().Round(time.Millisecond) - _, err = s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(tenant_id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), 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 tenant_id=? AND sender_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), 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 tenant_id=? AND user_id=? AND record_date=? ORDER BY id DESC LIMIT 1`, s.tenant(), 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 tenant_id=? AND record_id=? ORDER BY id ASC`, s.tenant(), 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 tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), 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 tenant_id=? AND record_id=?`, s.tenant(), 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 tenant_id=? AND meeting_id=? ORDER BY id ASC`, s.tenant(), meetingID) - return items, err -} - -func (s *Store) hydrateMeetingUsers(ctx context.Context, meeting *Meeting) error { - if meeting == nil { - return nil - } - seen := map[int64]bool{} - userIDs := make([]int64, 0, len(meeting.Participants)+2) - add := func(userID int64) { - if userID > 0 && !seen[userID] { - seen[userID] = true - userIDs = append(userIDs, userID) - } - } - add(meeting.OrganizerID) - add(meeting.RecorderID) - for _, participant := range meeting.Participants { - add(participant.UserID) - } - summaries, err := s.UserSummaries(ctx, userIDs) - if err != nil { - return err - } - meeting.Organizer = summaryOrDefault(meeting.OrganizerID, summaries) - meeting.Recorder = summaryOrDefault(meeting.RecorderID, summaries) - for i := range meeting.Participants { - meeting.Participants[i].User = summaryOrDefault(meeting.Participants[i].UserID, summaries) - } - return nil -} - -func (s *Store) UserSummaries(ctx context.Context, userIDs []int64) (map[int64]UserSummary, error) { - result := make(map[int64]UserSummary, len(userIDs)) - ids := uniquePositiveInt64s(userIDs) - if len(ids) == 0 { - return result, nil - } - - query, args, err := sqlx.In(`SELECT user_id,display_name,IFNULL(avatar_file_id,'') AS avatar_file_id - FROM im_org_members WHERE tenant_id=? AND employment_state='active' AND user_id IN (?) ORDER BY user_id ASC,id ASC`, s.tenant(), ids) - if err != nil { - return nil, err - } - var orgRows []struct { - UserID int64 `db:"user_id"` - DisplayName string `db:"display_name"` - AvatarFileID string `db:"avatar_file_id"` - } - if err := s.db.SelectContext(ctx, &orgRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { - return nil, err - } - for _, row := range orgRows { - if _, ok := result[row.UserID]; ok { - continue - } - result[row.UserID] = UserSummary{UserID: row.UserID, DisplayName: strings.TrimSpace(row.DisplayName), AvatarFileID: row.AvatarFileID} - } - - if s.core != nil { - if err := s.core.populateUserSummaries(ctx, s.tenant(), ids, result); err != nil { - return nil, err - } - } - - for _, id := range ids { - result[id] = summaryOrDefault(id, result) - } - return result, nil -} - -func uniquePositiveInt64s(values []int64) []int64 { - seen := map[int64]bool{} - out := make([]int64, 0, len(values)) - for _, value := range values { - if value <= 0 || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - return out -} - -func summaryOrDefault(userID int64, summaries map[int64]UserSummary) UserSummary { - if userID <= 0 { - return UserSummary{} - } - summary := summaries[userID] - if summary.UserID == 0 { - summary.UserID = userID - } - return summary -} - -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 tenant_id=? AND meeting_id=? AND note_file_id=? LIMIT 1`, s.tenant(), meetingID, noteFileID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - if err := s.hydrateMeetingNoteFileUploader(ctx, &file); err != nil { - return nil, err - } - return &file, nil -} - -func (s *Store) hydrateMeetingNoteFileUploader(ctx context.Context, file *MeetingNoteFile) error { - if file == nil { - return nil - } - summaries, err := s.UserSummaries(ctx, []int64{file.UploaderID}) - if err != nil { - return err - } - file.Uploader = summaryOrDefault(file.UploaderID, summaries) - return nil -} - -func (s *Store) hydrateMeetingNoteFilesUploaders(ctx context.Context, files []MeetingNoteFile) error { - if len(files) == 0 { - return nil - } - userIDs := make([]int64, 0, len(files)) - for _, file := range files { - userIDs = append(userIDs, file.UploaderID) - } - summaries, err := s.UserSummaries(ctx, userIDs) - if err != nil { - return err - } - for i := range files { - files[i].Uploader = summaryOrDefault(files[i].UploaderID, summaries) - } - return 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 reportMemberScope(departmentID string) ([]string, []any) { - where := []string{"employment_state='active'"} - args := []any{} - if departmentID = strings.TrimSpace(departmentID); departmentID != "" { - where = append(where, "department_id=?") - args = append(args, departmentID) - } - return where, args -} - -func prefixReportMemberScope(where []string) []string { - out := make([]string, 0, len(where)) - for _, condition := range where { - condition = strings.TrimSpace(condition) - switch { - case strings.HasPrefix(condition, "tenant_id"): - out = append(out, "m."+condition) - case strings.HasPrefix(condition, "employment_state"): - out = append(out, "m."+condition) - case strings.HasPrefix(condition, "department_id"): - out = append(out, "m."+condition) - default: - out = append(out, condition) - } - } - return out -} - -func isDuplicate(err error) bool { - return err != nil && strings.Contains(err.Error(), "Duplicate entry") -} - -func isMissingTable(err error) bool { - return err != nil && strings.Contains(err.Error(), "doesn't exist") -} diff --git a/internal/store/portal.go b/internal/store/portal.go new file mode 100644 index 0000000..efda921 --- /dev/null +++ b/internal/store/portal.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" +) + +const publishStatusPublished = 3 + +type Banner struct { + ID int64 `db:"id"` + ImageURL string `db:"image_url"` + AltText string `db:"alt_text"` + TargetType int `db:"target_type"` + TargetValue string `db:"target_value"` + Sort int `db:"sort"` +} + +type Content struct { + ContentKey string `db:"content_key"` + ContentType string `db:"content_type"` + Title string `db:"title"` + Summary string `db:"summary"` + BodyHTML string `db:"body_html"` + BodyText string `db:"body_text"` + CoverURL string `db:"cover_url"` + Version int `db:"version"` + PublishedAt string `db:"published_at"` + AssetIDs sql.NullString `db:"asset_ids"` +} + +type MemberLevel struct { + ID int64 `db:"id"` + Code string `db:"level_code"` + Name string `db:"name"` + IconURL string `db:"icon_url"` + UpgradeAmount int64 `db:"upgrade_amount"` + GiftAmount int64 `db:"gift_amount"` + DailyOrderLimit int `db:"daily_order_limit"` + CommissionRate int `db:"commission_rate"` + DailyWithdrawLimit int `db:"daily_withdraw_limit"` + RuleJSON json.RawMessage `db:"rule_json"` + Version int `db:"version"` + Sort int `db:"sort"` +} + +type MemberNotice struct { + ID int64 `db:"id"` + Title string `db:"title"` + Content string `db:"content"` + SourceType int `db:"source_type"` + StartAt string `db:"start_at"` + EndAt string `db:"end_at"` +} + +type TaskSummary struct { + TodayCount int `db:"today_count" json:"today_count"` + TodayCommissionAmount int64 `db:"today_commission_amount" json:"today_commission_amount"` + YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"` +} + +func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + var raw []byte + err := s.db.GetContext(ctx, &raw, `SELECT config_json FROM hg_site_config_version + WHERE tenant_id=? AND publish_status=? AND (effective_at IS NULL OR effective_at<=UTC_TIMESTAMP(3)) + ORDER BY COALESCE(effective_at,published_at,created_at) DESC,id DESC LIMIT 1`, tenantID, publishStatusPublished) + if errors.Is(err, sql.ErrNoRows) { + return json.RawMessage(`{}`), nil + } + return json.RawMessage(raw), err +} + +func (s *Store) ActiveBanners(ctx context.Context, tenantID int64, position string) ([]Banner, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + rows := make([]Banner, 0) + err := s.db.SelectContext(ctx, &rows, `SELECT id,image_url,alt_text,target_type,COALESCE(target_value,'') AS target_value,sort + FROM hg_banner WHERE tenant_id=? AND position=? AND publish_status=? + AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3)) + AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID, position, publishStatusPublished) + return rows, err +} + +func (s *Store) PublishedContent(ctx context.Context, tenantID int64, key string) (*Content, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + var row Content + err := s.db.GetContext(ctx, &row, `SELECT c.content_key,c.content_type,v.title,COALESCE(v.summary,'') AS summary, + v.body_html,COALESCE(v.body_text,'') AS body_text,COALESCE(v.cover_url,'') AS cover_url, + v.version_no AS version,COALESCE(DATE_FORMAT(c.published_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS published_at,v.asset_ids + FROM hg_content c JOIN hg_content_version v + ON v.tenant_id=c.tenant_id AND v.id=c.published_version_id + WHERE c.tenant_id=? AND c.content_key=? AND c.publish_status=? AND v.publish_status=? + AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3)) + AND c.deleted_at IS NULL LIMIT 1`, tenantID, key, publishStatusPublished, publishStatusPublished) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrResourceNotFound + } + return &row, err +} + +func (s *Store) ActiveMemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + rows := make([]MemberLevel, 0) + err := s.db.SelectContext(ctx, &rows, `SELECT l.id,l.level_code,l.name,l.icon_url,l.sort, + v.upgrade_amount,v.gift_amount,v.daily_order_limit,v.commission_rate,v.daily_withdraw_limit, + COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json,v.version_no AS version + FROM hg_app_member_level l JOIN hg_app_member_level_version v + ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id + WHERE l.tenant_id=? AND l.status=1 AND v.publish_status=? + AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3)) + AND l.deleted_at IS NULL ORDER BY l.sort ASC,l.id ASC`, tenantID, publishStatusPublished) + return rows, err +} + +func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int64, limit int) ([]MemberNotice, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + rows := make([]MemberNotice, 0) + err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type, + DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, + DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at + FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 + AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3) + ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit) + return rows, err +} + +func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + var row TaskSummary + err := s.db.GetContext(ctx, &row, `SELECT + (SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=? AND o.member_id=? + AND o.created_at>=UTC_DATE() AND o.created_at=UTC_DATE() AND t.settled_at=UTC_DATE()-INTERVAL 1 DAY AND y.settled_at=UTC_DATE() AND o.created_at 0 { + return nil, ErrPendingOrderExists + } + var dailyLimit int + err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l + LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3 + WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrLevelNotEligible + } + if err != nil { + return nil, err + } + if dailyLimit > 0 { + var todayCount int + if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=? + AND order_type=2 AND created_at>=UTC_DATE() AND created_at= dailyLimit { + return nil, ErrDailyLimitReached + } + } + var task struct { + ID int64 `db:"id"` + Title string `db:"title"` + CoverURL string `db:"cover_url"` + CommissionAmount int64 `db:"commission_amount"` + CommissionRate int `db:"commission_rate"` + PerMemberLimit int `db:"per_member_limit"` + } + err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit + FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=? + WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL + AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3)) + AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id + AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at=UTC_DATE() AND om.created_at 0 { + where += ` AND o.order_status=?` + args = append(args, status) + } + var total int64 + if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_trade_order o`+where, args...); err != nil { + return nil, err + } + args = append(args, limit, offset) + rows := make([]TaskOrder, 0) + err := s.db.SelectContext(ctx, &rows, orderSelectSQL()+where+` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...) + return &TaskOrderPage{Items: rows, Total: total}, err +} + +func (s *Store) TaskOrder(ctx context.Context, tenantID, memberID, orderID int64) (*TaskOrder, error) { + var row TaskOrder + err := s.db.GetContext(ctx, &row, orderSelectSQL()+` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2 AND o.id=? LIMIT 1`, tenantID, memberID, orderID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOrderNotFound + } + return &row, err +} + +func orderSelectSQL() string { + return `SELECT o.id,o.order_no,o.task_id,o.product_id,COALESCE(NULLIF(o.product_name,''),t.title,'') AS product_name, + COALESCE(NULLIF(p.cover_url,''),t.cover_url,'') AS cover_url,o.quantity,o.unit_price_amount, + o.total_amount,o.commission_amount,o.currency,o.member_level_id, + COALESCE(o.rule_snapshot,JSON_OBJECT()) AS rule_snapshot,o.message,o.order_status,o.freeze_status, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at, + COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at, + COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, + DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, + DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at, + COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description, + COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no, + COALESCE(s.submission_status,0) AS submission_status,COALESCE(s.evidence_text,'') AS evidence_text, + COALESCE(s.evidence_attachment_ids,JSON_ARRAY()) AS evidence_attachment_ids, + COALESCE(s.review_reason_code,'') AS review_reason_code,COALESCE(s.review_reason,'') AS review_reason, + COALESCE(s.allow_resubmit,2) AS allow_resubmit, + COALESCE(DATE_FORMAT(s.created_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS submitted_at, + COALESCE(DATE_FORMAT(s.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, + COALESCE(DATE_FORMAT(s.settled_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS settled_at + FROM hg_trade_order o LEFT JOIN hg_task t ON t.tenant_id=o.tenant_id AND t.id=o.task_id + LEFT JOIN hg_product p ON p.tenant_id=o.tenant_id AND p.id=o.product_id + LEFT JOIN hg_task_submission s ON s.tenant_id=o.tenant_id AND s.trade_order_id=o.id` +} + +func (s *Store) TaskStats(ctx context.Context, tenantID, memberID int64) (*TaskStats, error) { + var row TaskStats + err := s.db.GetContext(ctx, &row, `SELECT + COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount, + (SELECT COALESCE(SUM(ts.commission_amount),0) FROM hg_task_submission ts WHERE ts.tenant_id=? AND ts.member_id=? + AND ts.submission_status=4 AND ts.settled_at>=UTC_DATE() AND ts.settled_at=UTC_DATE()-INTERVAL 1 DAY AND ys.settled_at=UTC_DATE() AND tc.created_at=UTC_DATE() AND cc.completed_at 0 { + var attachmentCount int + err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment + WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, + input.TenantID, input.QRAttachmentID, input.MemberID) + if err != nil { + return nil, err + } + if attachmentCount != 1 { + return nil, ErrAttachmentInvalid + } + } + var accountCount int + if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account + WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil { + return nil, err + } + isDefault := 2 + if accountCount == 0 { + isDefault = 1 + } + result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account + (tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher, + mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status, + is_default,created_by,updated_by) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType, + input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher), + input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID, + isDefault, input.MemberID, input.MemberID) + if err != nil { + if isDuplicate(err) { + return nil, ErrPaymentAccountExists + } + return nil, err + } + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode, + BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask, + AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID, + VerifyStatus: 1, IsDefault: isDefault}, nil +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) { + var row PaymentAccount + err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask, + COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash, + account_no_mask,qr_attachment_id,verify_status,is_default, + DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, + DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at + FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`, + tenantID, memberID, accountID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPaymentAccountInvalid + } + return &row, err +} + +func (s *Store) ActiveWithdrawalChannel(ctx context.Context, tenantID int64, channelTypes []int) (*WithdrawalChannel, error) { + if len(channelTypes) == 0 { + return nil, ErrWithdrawalChannel + } + query, args, err := sqlx.In(`SELECT id,channel_code,name,channel_type,min_amount,max_amount, + fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel + WHERE tenant_id=? AND channel_type IN (?) AND direction IN (2,3) AND status=1 AND deleted_at IS NULL + ORDER BY CASE WHEN channel_type=? THEN 0 ELSE 1 END,sort,id LIMIT 1`, tenantID, channelTypes, channelTypes[0]) + if err != nil { + return nil, err + } + var row WithdrawalChannel + err = s.db.GetContext(ctx, &row, query, args...) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrWithdrawalChannel + } + return &row, err +} + +func (s *Store) WithdrawalWallet(ctx context.Context, tenantID, memberID int64) (*WithdrawalWallet, error) { + var row WithdrawalWallet + err := s.db.GetContext(ctx, &row, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status, + COALESCE(lv.daily_withdraw_limit,0) AS daily_limit, + (SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id + AND o.created_at>=UTC_DATE() AND o.created_at 0 && input.RequestAmount > channel.MaxAmount) { + return nil, ErrWithdrawalAmount + } + + var wallet WithdrawalWallet + err = tx.GetContext(ctx, &wallet, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status, + COALESCE(lv.daily_withdraw_limit,0) AS daily_limit, + (SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id + AND o.created_at>=UTC_DATE() AND o.created_at 0 && wallet.TodayCount >= wallet.DailyLimit { + return nil, ErrWithdrawalLimit + } + if wallet.AvailableAmount < input.RequestAmount { + return nil, ErrInsufficientBalance + } + + riskResult := json.RawMessage(`{"result":"passed","mode":"sandbox"}`) + result, err := tx.ExecContext(ctx, `INSERT INTO hg_withdrawal_order + (tenant_id,withdrawal_no,member_id,payment_account_id,channel_id,request_amount,fee_amount, + actual_amount,currency,account_snapshot_cipher,account_mask,bank_name,holder_mask,risk_result, + withdrawal_status,created_by,updated_by) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, input.TenantID, input.WithdrawalNo, input.MemberID, + input.PaymentAccountID, channel.ID, input.RequestAmount, input.FeeAmount, input.ActualAmount, + input.Currency, input.AccountSnapshotCipher, input.AccountMask, input.BankName, input.HolderMask, + riskResult, input.MemberID, input.MemberID) + if err != nil { + if isDuplicate(err) { + return nil, ErrIdempotencyConflict + } + return nil, err + } + orderID, err := result.LastInsertId() + if err != nil { + return nil, err + } + beforeAvailable := wallet.AvailableAmount + afterAvailable := beforeAvailable - input.RequestAmount + beforeFrozen := wallet.FrozenAmount + afterFrozen := beforeFrozen + input.RequestAmount + update, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1, + updated_at=UTC_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND available_amount=? AND frozen_amount=? AND account_status=1`, + afterAvailable, afterFrozen, wallet.AccountID, input.TenantID, beforeAvailable, beforeFrozen) + if err != nil { + return nil, err + } + if affected, _ := update.RowsAffected(); affected != 1 { + return nil, ErrConcurrentUpdate + } + availableLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.AvailableLedgerNo, + "available", 2, beforeAvailable, afterAvailable, "available", "取出申请冻结可用余额") + if err != nil { + return nil, err + } + _ = availableLedgerID + frozenLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.FrozenLedgerNo, + "frozen", 1, beforeFrozen, afterFrozen, "frozen", "取出申请增加冻结余额") + if err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `UPDATE hg_withdrawal_order SET freeze_ledger_id=? WHERE tenant_id=? AND id=?`, + frozenLedgerID, input.TenantID, orderID); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &WithdrawalOrder{ID: orderID, WithdrawalNo: input.WithdrawalNo, + PaymentAccountID: input.PaymentAccountID, ChannelID: channel.ID, ChannelName: channel.Name, + RequestAmount: input.RequestAmount, FeeAmount: input.FeeAmount, ActualAmount: input.ActualAmount, + Currency: input.Currency, AccountMask: input.AccountMask, BankName: input.BankName, + HolderMask: input.HolderMask, RiskResult: riskResult, Status: 1}, nil +} + +func insertWithdrawalLedger(ctx context.Context, tx *sqlx.Tx, input WithdrawalCreation, accountID int64, + ledgerNo, entryType string, direction int, beforeAmount, afterAmount int64, suffix, remark string) (int64, error) { + result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger + (tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount, + before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,UTC_TIMESTAMP(3))`, input.TenantID, ledgerNo, accountID, + input.MemberID, "withdraw_freeze", input.WithdrawalNo, entryType, direction, input.RequestAmount, + beforeAmount, afterAmount, input.Currency, "withdrawal:"+input.WithdrawalNo+":"+suffix, remark, input.MemberID) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (s *Store) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*WithdrawalPage, error) { + where := ` WHERE o.tenant_id=? AND o.member_id=?` + args := []any{tenantID, memberID} + if status > 0 { + where += ` AND o.withdrawal_status=?` + args = append(args, status) + } + var total int64 + if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_withdrawal_order o`+where, args...); err != nil { + return nil, err + } + args = append(args, limit, offset) + items := make([]WithdrawalOrder, 0) + err := s.db.SelectContext(ctx, &items, withdrawalSelectSQL()+where+ + ` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...) + return &WithdrawalPage{Items: items, Total: total}, err +} + +func (s *Store) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*WithdrawalOrder, error) { + var row WithdrawalOrder + err := s.db.GetContext(ctx, &row, withdrawalSelectSQL()+ + ` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrWithdrawalNotFound + } + return &row, err +} + +func withdrawalByNo(ctx context.Context, db queryer, tenantID, memberID int64, no string) (*WithdrawalOrder, error) { + query := withdrawalSelectSQL() + ` WHERE o.tenant_id=? AND o.member_id=? AND o.withdrawal_no=? LIMIT 1` + var row WithdrawalOrder + err := db.GetContext(ctx, &row, query, tenantID, memberID, no) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrWithdrawalNotFound + } + return &row, err +} + +func withdrawalSelectSQL() string { + return `SELECT o.id,o.withdrawal_no,o.payment_account_id,o.channel_id,COALESCE(c.name,'') AS channel_name, + o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask, + COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code, + o.review_reason,o.external_no, + COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, + COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at, + COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, + DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, + DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at + FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` +} + +func maxInt64(left, right int64) int64 { + if left > right { + return left + } + return right +} diff --git a/internal/tableparse/tableparse.go b/internal/tableparse/tableparse.go deleted file mode 100644 index edcca6e..0000000 --- a/internal/tableparse/tableparse.go +++ /dev/null @@ -1,262 +0,0 @@ -package tableparse - -import ( - "encoding/csv" - "fmt" - "io" - "math" - "os" - "strconv" - "strings" - - "github.com/xuri/excelize/v2" -) - -const ( - maxSampleRows = 20 - maxRows = 5000 -) - -type Profile struct { - Columns []ColumnStat `json:"columns"` - SampleRows [][]string `json:"sample_rows"` - RowCount int `json:"row_count"` - Stats map[string]NumericStat `json:"stats,omitempty"` - Outliers []Outlier `json:"outliers,omitempty"` - Parsed bool `json:"parsed"` - Message string `json:"message,omitempty"` -} - -type ColumnStat struct { - Name string `json:"name"` - Type string `json:"type"` - Empty int `json:"empty"` -} - -type NumericStat struct { - Count int `json:"count"` - Sum float64 `json:"sum"` - Avg float64 `json:"avg"` - Min float64 `json:"min"` - Max float64 `json:"max"` -} - -type Outlier struct { - Row int `json:"row"` - Column string `json:"column"` - Description string `json:"description"` -} - -func Parse(path, fileType string) (Profile, error) { - switch strings.ToLower(strings.TrimSpace(fileType)) { - case "csv": - return parseCSV(path) - case "xlsx": - return parseXLSX(path) - case "xls": - return Profile{Parsed: false, Message: "legacy xls parsing is not supported yet"}, nil - default: - return Profile{}, fmt.Errorf("unsupported table file type: %s", fileType) - } -} - -func parseCSV(path string) (Profile, error) { - f, err := os.Open(path) - if err != nil { - return Profile{}, err - } - defer f.Close() - reader := csv.NewReader(f) - reader.FieldsPerRecord = -1 - rows := make([][]string, 0, maxRows) - for len(rows) < maxRows { - row, err := reader.Read() - if err == io.EOF { - break - } - if err != nil { - return Profile{}, err - } - if emptyRow(row) { - continue - } - rows = append(rows, trimRow(row)) - } - return profileRows(rows), nil -} - -func parseXLSX(path string) (Profile, error) { - f, err := excelize.OpenFile(path) - if err != nil { - return Profile{}, err - } - defer f.Close() - for _, sheet := range f.GetSheetList() { - iter, err := f.Rows(sheet) - if err != nil { - continue - } - cleaned := make([][]string, 0, maxRows) - for iter.Next() { - row, err := iter.Columns() - if err != nil { - _ = iter.Close() - return Profile{}, err - } - if emptyRow(row) { - continue - } - cleaned = append(cleaned, trimRow(row)) - if len(cleaned) >= maxRows { - break - } - } - if err := iter.Close(); err != nil { - return Profile{}, err - } - if len(cleaned) > 0 { - return profileRows(cleaned), nil - } - } - return Profile{Parsed: false, Message: "no non-empty sheet found"}, nil -} - -func profileRows(rows [][]string) Profile { - if len(rows) == 0 { - return Profile{Parsed: false, Message: "empty table"} - } - headers := rows[0] - if len(headers) == 0 { - return Profile{Parsed: false, Message: "empty header"} - } - dataRows := rows[1:] - columnCount := len(headers) - columns := make([]ColumnStat, columnCount) - statsWork := make([]numericWork, columnCount) - for i, name := range headers { - name = strings.TrimSpace(name) - if name == "" { - name = fmt.Sprintf("列%d", i+1) - } - columns[i] = ColumnStat{Name: name, Type: "text"} - } - sample := make([][]string, 0, min(len(dataRows), maxSampleRows)) - for rowIndex, row := range dataRows { - normalized := normalizeWidth(row, columnCount) - if len(sample) < maxSampleRows { - sample = append(sample, normalized) - } - for col := 0; col < columnCount; col++ { - value := strings.TrimSpace(normalized[col]) - if value == "" { - columns[col].Empty++ - continue - } - if parsed, ok := parseNumber(value); ok { - statsWork[col].add(parsed, rowIndex+2) - } - } - } - stats := map[string]NumericStat{} - outliers := []Outlier{} - for i := range columns { - work := statsWork[i] - nonEmpty := len(dataRows) - columns[i].Empty - if work.Count > 0 && work.Count*2 >= max(1, nonEmpty) { - columns[i].Type = "number" - stat := work.stat() - stats[columns[i].Name] = stat - if stat.Count >= 3 { - for _, point := range work.Points { - if point.Value == stat.Max && stat.Avg > 0 && point.Value > stat.Avg*2 { - outliers = append(outliers, Outlier{Row: point.Row, Column: columns[i].Name, Description: "数值明显高于平均值"}) - break - } - } - } - } - } - return Profile{Columns: columns, SampleRows: sample, RowCount: len(dataRows), Stats: stats, Outliers: outliers, Parsed: true} -} - -type numericWork struct { - Count int - Sum float64 - Min float64 - Max float64 - Points []numericPoint -} - -type numericPoint struct { - Row int - Value float64 -} - -func (w *numericWork) add(value float64, row int) { - if w.Count == 0 { - w.Min = value - w.Max = value - } else { - w.Min = math.Min(w.Min, value) - w.Max = math.Max(w.Max, value) - } - w.Count++ - w.Sum += value - if len(w.Points) < 200 { - w.Points = append(w.Points, numericPoint{Row: row, Value: value}) - } -} - -func (w numericWork) stat() NumericStat { - avg := 0.0 - if w.Count > 0 { - avg = w.Sum / float64(w.Count) - } - return NumericStat{Count: w.Count, Sum: w.Sum, Avg: avg, Min: w.Min, Max: w.Max} -} - -func parseNumber(value string) (float64, bool) { - value = strings.TrimSpace(strings.ReplaceAll(value, ",", "")) - value = strings.TrimSuffix(value, "%") - parsed, err := strconv.ParseFloat(value, 64) - return parsed, err == nil -} - -func normalizeWidth(row []string, width int) []string { - out := make([]string, width) - for i := 0; i < width && i < len(row); i++ { - out[i] = strings.TrimSpace(row[i]) - } - return out -} - -func trimRow(row []string) []string { - out := make([]string, len(row)) - for i := range row { - out[i] = strings.TrimSpace(row[i]) - } - return out -} - -func emptyRow(row []string) bool { - for _, value := range row { - if strings.TrimSpace(value) != "" { - return false - } - } - return true -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/tableparse/tableparse_test.go b/internal/tableparse/tableparse_test.go deleted file mode 100644 index a529867..0000000 --- a/internal/tableparse/tableparse_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package tableparse - -import ( - "os" - "path/filepath" - "testing" - - "github.com/xuri/excelize/v2" -) - -func TestParseCSVBuildsProfile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sales.csv") - if err := os.WriteFile(path, []byte("date,sales,orders\n8/1,100,2\n8/2,200,4\n"), 0o600); err != nil { - t.Fatal(err) - } - profile, err := Parse(path, "csv") - if err != nil { - t.Fatal(err) - } - if !profile.Parsed || profile.RowCount != 2 { - t.Fatalf("profile parsed=%v rowCount=%d, want parsed=true rowCount=2", profile.Parsed, profile.RowCount) - } - if got := profile.Stats["sales"].Sum; got != 300 { - t.Fatalf("sales sum = %v, want 300", got) - } -} - -func TestParseXLSXBuildsProfile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sales.xlsx") - f := excelize.NewFile() - if err := f.SetSheetRow("Sheet1", "A1", &[]any{"date", "sales"}); err != nil { - t.Fatal(err) - } - if err := f.SetSheetRow("Sheet1", "A2", &[]any{"8/1", 120}); err != nil { - t.Fatal(err) - } - if err := f.SaveAs(path); err != nil { - t.Fatal(err) - } - profile, err := Parse(path, "xlsx") - if err != nil { - t.Fatal(err) - } - if !profile.Parsed || profile.Columns[1].Type != "number" { - t.Fatalf("profile parsed=%v sales type=%q, want parsed=true number", profile.Parsed, profile.Columns[1].Type) - } -} diff --git a/internal/tasks/errors.go b/internal/tasks/errors.go new file mode 100644 index 0000000..5f030ba --- /dev/null +++ b/internal/tasks/errors.go @@ -0,0 +1,14 @@ +package tasks + +import "errors" + +var ( + ErrHallIDInvalid = errors.New("hall id invalid") + ErrOrderIDInvalid = errors.New("order id invalid") + ErrPageInvalid = errors.New("page invalid") + ErrPageSizeInvalid = errors.New("page size invalid") + ErrStatusInvalid = errors.New("status invalid") + ErrEvidenceInvalid = errors.New("evidence invalid") + ErrTaskDisabled = errors.New("task feature disabled") + ErrOutsideBusinessHour = errors.New("outside business hours") +) diff --git a/internal/tasks/service.go b/internal/tasks/service.go new file mode 100644 index 0000000..bab4ce7 --- /dev/null +++ b/internal/tasks/service.go @@ -0,0 +1,553 @@ +package tasks + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 + defaultDeadlineMinute = 15 +) + +var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60) + +type Repository interface { + CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) + TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) + TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) + ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error) + TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) + TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) + TaskStats(context.Context, int64, int64) (*store.TaskStats, error) + SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) + OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) +} + +type Service struct { + repository Repository + now func() time.Time +} + +type Hall struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IconURL string `json:"icon_url"` + Summary string `json:"summary"` + CommissionRate int `json:"commission_rate"` + RateUnit string `json:"rate_unit"` + ClientRoute string `json:"client_route"` +} + +type Eligibility struct { + Eligible bool `json:"eligible"` + Reason string `json:"reason"` + Message string `json:"message"` + AvailableAmount int64 `json:"available_amount"` + MinimumAmount int64 `json:"minimum_amount"` + TodayCount int `json:"today_count"` + DailyLimit int `json:"daily_limit"` + PendingOrderID int64 `json:"pending_order_id"` + BusinessStartAt string `json:"business_start_at"` + BusinessEndAt string `json:"business_end_at"` +} + +type Order struct { + ID int64 `json:"id"` + OrderNo string `json:"order_no"` + TaskID int64 `json:"task_id"` + Product Product `json:"product"` + Quantity int `json:"quantity"` + UnitPriceAmount int64 `json:"unit_price_amount"` + TotalAmount int64 `json:"total_amount"` + CommissionAmount int64 `json:"commission_amount"` + Currency string `json:"currency"` + Status string `json:"status"` + StatusText string `json:"status_text"` + Message string `json:"message"` + DeadlineAt string `json:"deadline_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Task *TaskDetail `json:"task,omitempty"` + RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"` + Submission *Submission `json:"submission,omitempty"` + Timeline []TimelineEntry `json:"timeline,omitempty"` + Ledgers []map[string]any `json:"ledgers,omitempty"` + AllowedActions []string `json:"allowed_actions,omitempty"` +} + +type Product struct { + ID int64 `json:"id"` + Name string `json:"name"` + CoverURL string `json:"cover_url"` +} + +type TaskDetail struct { + Title string `json:"title"` + Description string `json:"description"` + DetailHTML string `json:"detail_html"` +} + +type Submission struct { + SubmissionNo string `json:"submission_no"` + Status string `json:"status"` + StatusText string `json:"status_text"` + EvidenceText string `json:"evidence_text"` + EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"` + ReviewReasonCode string `json:"review_reason_code"` + ReviewReason string `json:"review_reason"` + AllowResubmit bool `json:"allow_resubmit"` + SubmittedAt string `json:"submitted_at"` + ReviewedAt string `json:"reviewed_at"` + SettledAt string `json:"settled_at"` +} + +type TimelineEntry struct { + Status string `json:"status"` + Text string `json:"text"` + At string `json:"at"` +} + +type OrderPage struct { + Items []Order `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` +} + +type Stats struct { + AvailableAmount int64 `json:"available_amount"` + FrozenAmount int64 `json:"frozen_amount"` + TotalAssetAmount int64 `json:"total_asset_amount"` + TodayCommissionAmount int64 `json:"today_commission_amount"` + YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"` + TodayClaimCount int `json:"today_claim_count"` + TodayCompletedCount int `json:"today_completed_count"` +} + +type SubmitInput struct { + EvidenceText string `json:"evidence_text"` + EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"` +} + +type SubmitResult struct { + SubmissionNo string `json:"submission_no"` + Status string `json:"status"` + SubmittedAt string `json:"submitted_at"` + OrderStatus string `json:"order_status"` +} + +type taskPolicy struct { + Enabled bool + MinimumAmount int64 + Start string + End string + Deadline time.Duration +} + +func New(repository Repository) *Service { + return &Service{repository: repository, now: time.Now} +} + +func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) { + rows, err := s.repository.TaskHalls(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + items := make([]Hall, 0, len(rows)) + for _, row := range rows { + items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL, + Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute}) + } + return items, nil +} + +func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) { + if hallID <= 0 { + return nil, ErrHallIDInvalid + } + policy, err := s.policy(ctx, tenantID) + if err != nil { + return nil, err + } + row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID) + if err != nil { + return nil, err + } + result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount, + TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID, + BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"} + switch { + case !policy.Enabled: + result.reject("TASK_DISABLED", "任务功能未开启") + case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End): + result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间") + case row.MemberStatus != 1: + result.reject("ACCOUNT_RESTRICTED", "账户状态受限") + case !row.HallEligible: + result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅") + case row.WalletStatus != 1: + result.reject("ACCOUNT_RESTRICTED", "账户状态受限") + case row.AvailableAmount < policy.MinimumAmount: + result.reject("INSUFFICIENT_BALANCE", "可用余额不足") + case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: + result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") + case row.PendingOrderID > 0: + result.reject("PENDING_ORDER_EXISTS", "存在未完成订单") + } + return result, nil +} + +func (e *Eligibility) reject(reason, message string) { + e.Eligible = false + e.Reason = reason + e.Message = message +} + +func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) { + if hallID <= 0 { + return nil, ErrHallIDInvalid + } + policy, err := s.policy(ctx, tenantID) + if err != nil { + return nil, err + } + if !policy.Enabled { + return nil, ErrTaskDisabled + } + if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) { + return nil, ErrOutsideBusinessHour + } + now := s.now().UTC() + row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID, + HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)}) + if err != nil { + return nil, err + } + result := orderFromStore(*row, false) + return &result, nil +} + +func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) { + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = defaultPageSize + } + if page < 1 { + return nil, ErrPageInvalid + } + if pageSize < 1 || pageSize > maxPageSize { + return nil, ErrPageSizeInvalid + } + statusCode, ok := orderStatusCode(status) + if !ok { + return nil, ErrStatusInvalid + } + rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]Order, 0, len(rows.Items)) + for _, row := range rows.Items { + items = append(items, orderFromStore(row, false)) + } + return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil +} + +func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) { + if orderID <= 0 { + return nil, ErrOrderIDInvalid + } + row, err := s.repository.TaskOrder(ctx, tenantID, memberID, orderID) + if err != nil { + return nil, err + } + ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo}) + if err != nil { + return nil, err + } + result := orderFromStore(*row, true) + result.Ledgers = ledgers + return &result, nil +} + +func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) { + row, err := s.repository.TaskStats(ctx, tenantID, memberID) + if err != nil { + return nil, err + } + return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount, + TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount, + YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount, + TodayCompletedCount: row.TodayCompletedCount}, nil +} + +func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) { + if orderID <= 0 { + return nil, ErrOrderIDInvalid + } + input.EvidenceText = strings.TrimSpace(input.EvidenceText) + if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 || + (input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) { + return nil, ErrEvidenceInvalid + } + now := s.now().UTC() + row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID, + OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText, + AttachmentIDs: input.EvidenceAttachmentIDs}) + if err != nil { + return nil, err + } + return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing", + SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil +} + +func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) { + raw, err := s.repository.CurrentSiteConfig(ctx, tenantID) + if err != nil { + return taskPolicy{}, err + } + var config map[string]any + if json.Unmarshal(raw, &config) != nil { + config = map[string]any{} + } + minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes")) + if minutes <= 0 || minutes > 24*60 { + minutes = defaultDeadlineMinute + } + start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00") + end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59") + return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")), + MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))), + Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil +} + +func orderFromStore(row store.TaskOrder, detail bool) Order { + status := orderStatusName(row.OrderStatus) + result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID, + Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity, + UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount, + Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message, + DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + if !detail { + return result + } + result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML} + result.RuleSnapshot = row.RuleSnapshot + result.Timeline = orderTimeline(row) + result.AllowedActions = allowedActions(row) + if row.SubmissionNo != "" { + ids := make([]int64, 0) + _ = json.Unmarshal(row.EvidenceIDs, &ids) + result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus), + StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText, + EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason, + AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt} + } + return result +} + +func orderTimeline(row store.TaskOrder) []TimelineEntry { + items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}} + if row.MatchedAt != "" { + items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt}) + } + if row.SubmittedAt != "" { + items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt}) + } + if row.ReviewedAt != "" { + items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt}) + } + if row.SettledAt != "" { + items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt}) + } + if row.CompletedAt != "" { + items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt}) + } + return items +} + +func allowedActions(row store.TaskOrder) []string { + switch row.OrderStatus { + case 2: + return []string{"submit", "contact_support"} + case 3, 5, 6, 8: + return []string{"contact_support"} + default: + return []string{} + } +} + +func orderStatusCode(value string) (int, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return 0, true + case "matching": + return 1, true + case "pending": + return 2, true + case "processing": + return 3, true + case "completed": + return 4, true + case "expired": + return 5, true + case "frozen": + return 6, true + case "cancelled": + return 7, true + case "failed": + return 8, true + default: + return 0, false + } +} + +func orderStatusName(status int) string { + names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"} + if value, ok := names[status]; ok { + return value + } + return "unknown" +} + +func orderStatusText(status int) string { + names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"} + if value, ok := names[status]; ok { + return value + } + return "未知状态" +} + +func submissionStatusName(status int) string { + names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"} + return names[status] +} + +func submissionStatusText(status int) string { + names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"} + return names[status] +} + +func withinBusinessHours(now time.Time, start, end string) bool { + current := now.Hour()*3600 + now.Minute()*60 + now.Second() + startSecond := parseClock(start) + endSecond := parseClock(end) + if startSecond <= endSecond { + return current >= startSecond && current <= endSecond + } + return current >= startSecond || current <= endSecond +} + +func parseClock(value string) int { + parsed, err := time.Parse("15:04:05", value) + if err != nil { + return 0 + } + return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second() +} + +func clockValue(value any, fallback string) string { + raw, ok := value.(string) + if !ok { + return fallback + } + raw = strings.TrimSpace(raw) + for _, layout := range []string{"15:04:05", "15:04"} { + if parsed, err := time.Parse(layout, raw); err == nil { + return parsed.Format("15:04:05") + } + } + return fallback +} + +func findValue(config map[string]any, keys ...string) any { + for _, key := range keys { + for candidate, value := range config { + if strings.EqualFold(candidate, key) { + return value + } + } + } + for _, value := range config { + if nested, ok := value.(map[string]any); ok { + if found := findValue(nested, keys...); found != nil { + return found + } + } + } + return nil +} + +func boolValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case float64: + return typed == 1 + case string: + return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") + default: + return false + } +} + +func intValue(value any) int { + return int(int64Value(value)) +} + +func int64Value(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} + +func max64(left, right int64) int64 { + if left > right { + return left + } + return right +} + +func validUniqueIDs(ids []int64) bool { + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + return false + } + if _, exists := seen[id]; exists { + return false + } + seen[id] = struct{}{} + } + return true +} + +func newNumber(prefix string, now time.Time) string { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000) + } + return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes)) +} diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go new file mode 100644 index 0000000..dd91ffb --- /dev/null +++ b/internal/tasks/service_test.go @@ -0,0 +1,117 @@ +package tasks + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +type fakeRepository struct { + config json.RawMessage + eligibility *store.TaskEligibility + order *store.TaskOrder + claimed store.ClaimTaskInput + submitted store.TaskSubmissionInput +} + +func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { + return f.config, nil +} +func (f *fakeRepository) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) { + return []store.TaskHall{{ID: 1, Code: "DEFAULT", Name: "任务大厅"}}, nil +} +func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) { + return f.eligibility, nil +} +func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) { + f.claimed = input + return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY", + DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil +} +func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) { + return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil +} +func (f *fakeRepository) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) { + return f.order, nil +} +func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) { + return &store.TaskStats{AvailableAmount: 100, FrozenAmount: 25}, nil +} +func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) { + f.submitted = input + return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3, + SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil +} +func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) { + return []map[string]any{}, nil +} + +func TestEligibilityAppliesPublishedPolicy(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), + eligibility: &store.TaskEligibility{MemberStatus: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} + service := New(repository) + service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) } + + result, err := service.Eligibility(context.Background(), 0, 10, 1) + if err != nil { + t.Fatal(err) + } + if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 { + t.Fatalf("unexpected eligibility: %#v", result) + } +} + +func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{}`)} + service := New(repository) + + _, err := service.Claim(context.Background(), 0, 10, 1) + if !errors.Is(err, ErrTaskDisabled) { + t.Fatalf("expected task disabled, got %v", err) + } +} + +func TestClaimBuildsMatchingOrder(t *testing.T) { + repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)} + service := New(repository) + now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) + service.now = func() time.Time { return now } + + result, err := service.Claim(context.Background(), 0, 10, 2) + if err != nil { + t.Fatal(err) + } + if result.Status != "matching" || repository.claimed.HallID != 2 || + !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { + t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) + } +} + +func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) { + repository := &fakeRepository{} + service := New(repository) + service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) } + + if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) { + t.Fatalf("expected evidence error, got %v", err) + } + result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}}) + if err != nil { + t.Fatal(err) + } + if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" { + t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted) + } +} + +func TestOrdersRejectsUnknownStatus(t *testing.T) { + service := New(&fakeRepository{}) + _, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status") + if !errors.Is(err, ErrStatusInvalid) { + t.Fatalf("expected status error, got %v", err) + } +} diff --git a/scripts/migrate_workbench_tables.sh b/scripts/migrate_workbench_tables.sh deleted file mode 100755 index 8bb9f95..0000000 --- a/scripts/migrate_workbench_tables.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - migrate_workbench_tables.sh --host HOST --port PORT --user USER --password PASSWORD --source meeting_im --target workbench [--backup-dir DIR] - -Migrates im_workbench-owned tables from the shared IM database into the standalone workbench database. -The target tables are dropped and recreated from the source dump. Source and target table dumps are -written before any target changes. -EOF -} - -HOST="" -PORT="3306" -USER="" -PASSWORD="" -SOURCE_DB="meeting_im" -TARGET_DB="workbench" -BACKUP_DIR="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --host) HOST="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; - --user) USER="$2"; shift 2 ;; - --password) PASSWORD="$2"; shift 2 ;; - --source) SOURCE_DB="$2"; shift 2 ;; - --target) TARGET_DB="$2"; shift 2 ;; - --backup-dir) BACKUP_DIR="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; - esac -done - -if [[ -z "$HOST" || -z "$USER" || -z "$PASSWORD" || -z "$SOURCE_DB" || -z "$TARGET_DB" ]]; then - usage >&2 - exit 2 -fi - -TABLES=( - im_workbench_apps - im_org_departments - im_org_members - im_org_visibility_overrides - im_attendance_rules - im_attendance_shifts - im_attendance_records - im_attendance_checkins - im_attendance_appeals - im_meetings - im_meeting_participants - im_meeting_note_files - im_daily_reports - im_monthly_reports - im_mailbox_config - im_mailbox_submissions - im_feedback_config - im_feedback_submissions - wb_ai_capability_config - wb_ai_document - wb_ai_idempotency_record - wb_ai_meeting_summary - wb_ai_prompt_template - wb_ai_record - wb_ai_table_analysis - wb_ai_task - wb_ai_task_step - wb_ai_usage_log -) - -timestamp="$(date +%Y%m%d%H%M%S)" -if [[ -z "$BACKUP_DIR" ]]; then - BACKUP_DIR="./tmp/workbench_migration_${HOST}_${PORT}_${timestamp}" -fi -mkdir -p "$BACKUP_DIR" - -MYSQL=(mysql -h "$HOST" -P "$PORT" -u "$USER" "-p$PASSWORD" --default-character-set=utf8mb4) -MYSQLDUMP=(mysqldump -h "$HOST" -P "$PORT" -u "$USER" "-p$PASSWORD" --default-character-set=utf8mb4 --single-transaction --skip-add-locks --set-gtid-purged=OFF) - -echo "backup dir: $BACKUP_DIR" -echo "checking source tables..." -"${MYSQL[@]}" -N -e "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='${SOURCE_DB}'" > "$BACKUP_DIR/${SOURCE_DB}_tables.txt" - -existing_tables=() -for table in "${TABLES[@]}"; do - if grep -qx "$table" "$BACKUP_DIR/${SOURCE_DB}_tables.txt"; then - existing_tables+=("$table") - else - echo "warning: source table ${SOURCE_DB}.${table} does not exist, skipping" >&2 - fi -done - -if [[ ${#existing_tables[@]} -eq 0 ]]; then - echo "no source tables found" >&2 - exit 1 -fi - -echo "creating target database if needed..." -"${MYSQL[@]}" -e "CREATE DATABASE IF NOT EXISTS \`${TARGET_DB}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" - -echo "backing up source tables..." -"${MYSQLDUMP[@]}" "$SOURCE_DB" "${existing_tables[@]}" > "$BACKUP_DIR/${SOURCE_DB}_workbench_tables.sql" - -target_existing=() -for table in "${existing_tables[@]}"; do - if "${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${TARGET_DB}' AND TABLE_NAME='${table}'" | grep -qx "1"; then - target_existing+=("$table") - fi -done -if [[ ${#target_existing[@]} -gt 0 ]]; then - echo "backing up existing target tables..." - "${MYSQLDUMP[@]}" "$TARGET_DB" "${target_existing[@]}" > "$BACKUP_DIR/${TARGET_DB}_workbench_tables_before.sql" -fi - -drop_sql="SET FOREIGN_KEY_CHECKS=0;" -for table in "${existing_tables[@]}"; do - drop_sql+="DROP TABLE IF EXISTS \`${TARGET_DB}\`.\`${table}\`;" -done -drop_sql+="SET FOREIGN_KEY_CHECKS=1;" - -echo "dropping target tables..." -"${MYSQL[@]}" -e "$drop_sql" - -echo "importing source dump into target..." -sed -E \ - -e "s/^\s*CREATE DATABASE .*//" \ - -e "s/^\s*USE \`${SOURCE_DB}\`;//" \ - -e "s/^\s*USE ${SOURCE_DB};//" \ - -e "/^\s*LOCK TABLES /d" \ - -e "/^\s*UNLOCK TABLES;/d" \ - "$BACKUP_DIR/${SOURCE_DB}_workbench_tables.sql" | "${MYSQL[@]}" "$TARGET_DB" - -echo "row count comparison:" -printf "%-34s %12s %12s\n" "table" "source" "target" -failed=0 -for table in "${existing_tables[@]}"; do - src_count="$("${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM \`${SOURCE_DB}\`.\`${table}\`")" - dst_count="$("${MYSQL[@]}" -N -e "SELECT COUNT(*) FROM \`${TARGET_DB}\`.\`${table}\`")" - printf "%-34s %12s %12s\n" "$table" "$src_count" "$dst_count" - if [[ "$src_count" != "$dst_count" ]]; then - failed=1 - fi -done | tee "$BACKUP_DIR/row_counts.txt" - -if [[ "$failed" -ne 0 ]]; then - echo "row count mismatch" >&2 - exit 1 -fi - -echo "migration complete"