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 0000000..942cf37 Binary files /dev/null and b/docs/移动端任务与账户平台需求文档.assets/figure-01.png differ diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-02.png b/docs/移动端任务与账户平台需求文档.assets/figure-02.png new file mode 100644 index 0000000..614800b Binary files /dev/null and b/docs/移动端任务与账户平台需求文档.assets/figure-02.png differ diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-03.png b/docs/移动端任务与账户平台需求文档.assets/figure-03.png new file mode 100644 index 0000000..00f1073 Binary files /dev/null and b/docs/移动端任务与账户平台需求文档.assets/figure-03.png differ diff --git a/docs/移动端任务与账户平台需求文档.assets/figure-04.png b/docs/移动端任务与账户平台需求文档.assets/figure-04.png new file mode 100644 index 0000000..1fd364d Binary files /dev/null and b/docs/移动端任务与账户平台需求文档.assets/figure-04.png differ diff --git a/docs/移动端任务与账户平台需求文档.docx b/docs/移动端任务与账户平台需求文档.docx new file mode 100644 index 0000000..787c6d3 Binary files /dev/null and b/docs/移动端任务与账户平台需求文档.docx differ 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"