feat: migrate workbench services
This commit is contained in:
+1
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='<API Key,多个以逗号分隔>'
|
||||
```
|
||||
|
||||
默认监听 `: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: <api key>
|
||||
X-Request-ID: <request id>
|
||||
X-Device-ID: <optional 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)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+11
-39
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,230 +0,0 @@
|
||||
-- IM 工作台 AI 建表 SQL
|
||||
-- Run on the shared meeting_im database.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_capability_config (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
tenant_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
feature VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash',
|
||||
daily_limit INT NOT NULL DEFAULT 0,
|
||||
concurrency_limit INT NOT NULL DEFAULT 1,
|
||||
max_input_chars INT NOT NULL DEFAULT 0,
|
||||
max_file_size_mb INT NOT NULL DEFAULT 0,
|
||||
config_json JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_tenant_feature (tenant_id, feature)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_task (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
task_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_code VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
feature VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
source_ref_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
input_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||
input_snapshot JSON NULL,
|
||||
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||
result_ref_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
result_json JSON NULL,
|
||||
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
started_at DATETIME(3) NULL,
|
||||
finished_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_task_id (task_id),
|
||||
KEY idx_user_feature_time (tenant_id, user_id, feature, created_at),
|
||||
KEY idx_status_time (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_task_step (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
task_id VARCHAR(64) NOT NULL,
|
||||
step VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
started_at DATETIME(3) NULL,
|
||||
finished_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_task_step (task_id, step)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_record (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_code VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
record_type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
summary VARCHAR(512) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL,
|
||||
detail_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_record_id (record_id),
|
||||
KEY idx_user_type_time (tenant_id, user_id, record_type, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_document (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
document_type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
content_markdown MEDIUMTEXT NOT NULL,
|
||||
content_plain MEDIUMTEXT NULL,
|
||||
sections_json JSON NULL,
|
||||
source_excerpt VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_record_id (record_id),
|
||||
KEY idx_tenant_user_time (tenant_id, user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_table_analysis (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
file_id VARCHAR(128) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_type VARCHAR(16) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
topic VARCHAR(128) NOT NULL,
|
||||
metrics_json JSON NULL,
|
||||
preview_json JSON NULL,
|
||||
alerts_json JSON NULL,
|
||||
interpretation MEDIUMTEXT NULL,
|
||||
export_file_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_record_id (record_id),
|
||||
KEY idx_file_id (tenant_id, file_id),
|
||||
KEY idx_tenant_user_time (tenant_id, user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_meeting_summary (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
summary_id VARCHAR(64) NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_code VARCHAR(64) NOT NULL,
|
||||
meeting_id VARCHAR(128) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
conclusions_json JSON NULL,
|
||||
decisions_json JSON NULL,
|
||||
action_items_json JSON NULL,
|
||||
source_file_ids_json JSON NULL,
|
||||
prompt_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model VARCHAR(64) NOT NULL DEFAULT '',
|
||||
edited_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||
edited_at DATETIME(3) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_summary_id (summary_id),
|
||||
UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id),
|
||||
KEY idx_user_time (tenant_id, user_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_usage_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_code VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
feature VARCHAR(64) NOT NULL,
|
||||
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
provider VARCHAR(32) NOT NULL,
|
||||
model VARCHAR(64) NOT NULL,
|
||||
provider_request_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
total_tokens INT NOT NULL DEFAULT 0,
|
||||
latency_ms INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
error_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
input_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||
output_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_feature_day (tenant_id, user_id, feature, created_at),
|
||||
KEY idx_provider_time (provider, model, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_idempotency_record (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
request_hash VARCHAR(128) NOT NULL,
|
||||
response_code INT NOT NULL DEFAULT 0,
|
||||
response_body JSON NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wb_ai_prompt_template (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
template_key VARCHAR(64) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'deepseek',
|
||||
model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash',
|
||||
system_prompt MEDIUMTEXT NOT NULL,
|
||||
user_prompt_template MEDIUMTEXT NOT NULL,
|
||||
response_schema JSON NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_template_version (template_key, version),
|
||||
KEY idx_template_enabled (template_key, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
INSERT INTO wb_ai_capability_config
|
||||
(tenant_id, tenant_code, feature, enabled, model, daily_limit, concurrency_limit, max_input_chars, max_file_size_mb, config_json, created_at, updated_at)
|
||||
VALUES
|
||||
(0, '', 'smart_document', 0, 'deepseek-flash', 20, 1, 1000, 0, JSON_OBJECT('entry_title', '智能文档'), NOW(3), NOW(3)),
|
||||
(0, '', 'smart_table', 0, 'deepseek-v4-pro', 10, 1, 100, 20, JSON_OBJECT('entry_title', '智能表格', 'allowed_file_types', JSON_ARRAY('xlsx', 'xls', 'csv')), NOW(3), NOW(3)),
|
||||
(0, '', 'text_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||
(0, '', 'daily_report_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||
(0, '', 'monthly_report_polish', 0, 'deepseek-flash', 30, 3, 1000, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)),
|
||||
(0, '', 'meeting_summary', 0, 'deepseek-v4-pro', 10, 1, 0, 0, JSON_OBJECT('entry_title', 'AI总结'), NOW(3), NOW(3))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
model = VALUES(model),
|
||||
daily_limit = VALUES(daily_limit),
|
||||
concurrency_limit = VALUES(concurrency_limit),
|
||||
max_input_chars = VALUES(max_input_chars),
|
||||
max_file_size_mb = VALUES(max_file_size_mb),
|
||||
config_json = VALUES(config_json),
|
||||
updated_at = NOW(3);
|
||||
@@ -1,442 +0,0 @@
|
||||
# 工作台与 Manager 内部配置运维说明
|
||||
|
||||
更新时间:2026-09-17
|
||||
|
||||
## 1. 背景
|
||||
|
||||
`im_workbench` 需要从 `im_manager` 读取系统配置,例如:
|
||||
|
||||
- `im.uid_encryption_key`
|
||||
|
||||
读取链路如下:
|
||||
|
||||
```text
|
||||
im_workbench
|
||||
-> GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key
|
||||
-> im_manager 校验 HMAC 签名
|
||||
-> 返回系统配置
|
||||
```
|
||||
|
||||
这个接口是服务间内部接口,不是普通前端接口。调用方必须带 HMAC 请求头:
|
||||
|
||||
```http
|
||||
X-Service-Name: im-workbench
|
||||
X-Timestamp: <unix seconds>
|
||||
X-Nonce: <random nonce>
|
||||
X-Signature: <hmac-sha256 hex>
|
||||
```
|
||||
|
||||
签名使用的密钥来自 `im_manager` 测试环境 Secret 中的:
|
||||
|
||||
```text
|
||||
manager-system / im-manager-secrets / IM_CONFIG_API_SECRET
|
||||
```
|
||||
|
||||
注意:不要把真实密钥写进 Git 文档或代码。
|
||||
|
||||
## 2. 为什么 .env 不生效
|
||||
|
||||
测试环境运行在 Kubernetes Pod 中,服务启动时不会自动读取仓库里的 `.env` 文件。
|
||||
|
||||
当前代码和启动方式是:
|
||||
|
||||
- `im_workbench` 只读取进程环境变量和 `IM_WORKBENCH_CONFIG` 指向的 JSON 配置文件。
|
||||
- `im_manager` 的 entrypoint 使用 Pod 环境变量做 `envsubst` 生成 `config.yaml`。
|
||||
- 两个服务都没有 `godotenv.Load()`、`source .env` 或 docker compose `env_file` 逻辑。
|
||||
|
||||
所以测试环境真正生效的是:
|
||||
|
||||
```text
|
||||
Kubernetes Secret / Deployment 环境变量
|
||||
```
|
||||
|
||||
不是仓库中的 `.env`。
|
||||
|
||||
## 3. 当前 Kubernetes 配置关系
|
||||
|
||||
### im_workbench
|
||||
|
||||
命名空间:
|
||||
|
||||
```text
|
||||
workbench-system
|
||||
```
|
||||
|
||||
Deployment:
|
||||
|
||||
```text
|
||||
im-workbench
|
||||
```
|
||||
|
||||
当前 Pod 里只显式注入:
|
||||
|
||||
```text
|
||||
IM_WORKBENCH_CONFIG=/etc/im-workbench/workbench.json
|
||||
```
|
||||
|
||||
`/etc/im-workbench/workbench.json` 来自:
|
||||
|
||||
```text
|
||||
workbench-system / im-workbench-secrets / workbench.json
|
||||
```
|
||||
|
||||
因此 workbench 的运行时配置要改这个 Secret 里的 `workbench.json`,或在 Deployment 里显式加环境变量。
|
||||
|
||||
### im_manager
|
||||
|
||||
命名空间:
|
||||
|
||||
```text
|
||||
manager-system
|
||||
```
|
||||
|
||||
Deployment:
|
||||
|
||||
```text
|
||||
im-manager
|
||||
```
|
||||
|
||||
Secret:
|
||||
|
||||
```text
|
||||
manager-system / im-manager-secrets
|
||||
```
|
||||
|
||||
其中和内部配置接口相关的 key:
|
||||
|
||||
```text
|
||||
IM_CONFIG_API_SERVICE_NAME
|
||||
IM_CONFIG_API_SECRET
|
||||
```
|
||||
|
||||
## 4. 必须配置什么
|
||||
|
||||
### 4.1 im_manager 需要允许 im_workbench 调配置接口
|
||||
|
||||
`im_manager` 的 `IM_CONFIG_API_SERVICE_NAME` 需要支持两个调用方:
|
||||
|
||||
```text
|
||||
im-server,im-workbench
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- `im_server` 也需要读取 manager 配置。
|
||||
- `im_workbench` 也需要读取 manager 配置。
|
||||
- 如果只配置 `im-workbench`,会影响 `im_server`。
|
||||
- 如果只配置 `im-server`,`im_workbench` 用正确 service name 调用会 401。
|
||||
|
||||
目标配置:
|
||||
|
||||
```text
|
||||
IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench
|
||||
```
|
||||
|
||||
`IM_CONFIG_API_SECRET` 保持现有值,不要改成空,也不要泄露到日志或文档。
|
||||
|
||||
### 4.2 im_workbench 需要配置 manager 连接信息
|
||||
|
||||
`workbench.json` 中需要有:
|
||||
|
||||
```json
|
||||
{
|
||||
"manager": {
|
||||
"base_url": "https://admin.118im.com",
|
||||
"service_name": "im-workbench",
|
||||
"secret": "<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET>",
|
||||
"uid_key_group": "im",
|
||||
"uid_key_keys": [
|
||||
"uid_encryption_key",
|
||||
"UID_ENCRYPTION_KEY",
|
||||
"uid_key"
|
||||
],
|
||||
"timeout_ms": 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以不用改 JSON,改成 Deployment 环境变量:
|
||||
|
||||
```text
|
||||
IM_WORKBENCH_MANAGER_BASE_URL=https://admin.118im.com
|
||||
IM_WORKBENCH_MANAGER_SERVICE_NAME=im-workbench
|
||||
IM_WORKBENCH_MANAGER_SECRET=<填 manager-system/im-manager-secrets 中的 IM_CONFIG_API_SECRET>
|
||||
```
|
||||
|
||||
建议优先统一放到 `workbench.json`,因为当前 workbench 已经通过 Secret 挂载 JSON 配置。
|
||||
|
||||
## 5. 推荐操作流程
|
||||
|
||||
### 步骤 1:更新 manager Secret
|
||||
|
||||
将:
|
||||
|
||||
```text
|
||||
manager-system / im-manager-secrets / IM_CONFIG_API_SERVICE_NAME
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```text
|
||||
im-server,im-workbench
|
||||
```
|
||||
|
||||
`IM_CONFIG_API_SECRET` 保持现有值。
|
||||
|
||||
### 步骤 2:更新 workbench Secret
|
||||
|
||||
从:
|
||||
|
||||
```text
|
||||
manager-system / im-manager-secrets / IM_CONFIG_API_SECRET
|
||||
```
|
||||
|
||||
取值,写入:
|
||||
|
||||
```text
|
||||
workbench-system / im-workbench-secrets / workbench.json
|
||||
```
|
||||
|
||||
目标字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"manager": {
|
||||
"base_url": "https://admin.118im.com",
|
||||
"service_name": "im-workbench",
|
||||
"secret": "<IM_CONFIG_API_SECRET>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3:重启服务
|
||||
|
||||
更新 Secret 后需要重启 Pod,确保进程重新读取配置。
|
||||
|
||||
建议顺序:
|
||||
|
||||
```text
|
||||
1. 重启 im-manager
|
||||
2. 重启 im-workbench
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 先让 manager 支持 `im-workbench` service name。
|
||||
- 再让 workbench 使用 `im-workbench` service name 去调用 manager。
|
||||
|
||||
## 6. 验证方法
|
||||
|
||||
### 6.1 验证 manager 是否接受 im-workbench
|
||||
|
||||
用 `IM_CONFIG_API_SECRET` 对请求签名,请求:
|
||||
|
||||
```text
|
||||
GET https://admin.118im.com/internal/im-config/third-party/im?keys=uid_encryption_key,UID_ENCRYPTION_KEY,uid_key
|
||||
X-Service-Name: im-workbench
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"values": {
|
||||
"uid_encryption_key": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果返回 401,重点检查:
|
||||
|
||||
- `IM_CONFIG_API_SERVICE_NAME` 是否包含 `im-workbench`
|
||||
- 请求里的 `X-Service-Name` 是否是 `im-workbench`
|
||||
- `IM_CONFIG_API_SECRET` 是否和 manager 当前 Secret 一致
|
||||
- 签名文本是否为:
|
||||
|
||||
```text
|
||||
METHOD
|
||||
RequestURI
|
||||
BodySHA256
|
||||
Timestamp
|
||||
Nonce
|
||||
```
|
||||
|
||||
### 6.2 验证 workbench 是否加载到 manager 配置
|
||||
|
||||
进入 workbench Pod 看环境和配置摘要,注意不要打印真实 secret:
|
||||
|
||||
```bash
|
||||
kubectl -n workbench-system exec <im-workbench-pod> -- sh -c 'env | grep IM_WORKBENCH_MANAGER'
|
||||
```
|
||||
|
||||
或查看挂载配置:
|
||||
|
||||
```bash
|
||||
kubectl -n workbench-system exec <im-workbench-pod> -- sh -c 'cat /etc/im-workbench/workbench.json'
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
```text
|
||||
manager.base_url = https://admin.118im.com
|
||||
manager.service_name = im-workbench
|
||||
manager.secret 非空
|
||||
```
|
||||
|
||||
### 6.3 验证业务接口
|
||||
|
||||
从 IM 登录获取 token 后,请求 workbench 接口:
|
||||
|
||||
```http
|
||||
GET https://api.118im.com/v1/workbench
|
||||
Authorization: Bearer <IM token>
|
||||
X-MeetingIM-APIKey: <api key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: <tenant code>
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0
|
||||
}
|
||||
```
|
||||
|
||||
再创建会议,确认 user id 编解码和展示名正常:
|
||||
|
||||
```http
|
||||
POST https://api.118im.com/v1/workbench/meetings
|
||||
Idempotency-Key: <unique id>
|
||||
Authorization: Bearer <IM token>
|
||||
X-MeetingIM-APIKey: <api key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: <tenant code>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "运维验证会议",
|
||||
"start_at": "2026-09-18T08:00:00Z",
|
||||
"end_at": "2026-09-18T09:00:00Z",
|
||||
"location": "",
|
||||
"recorder_user_id": "usr...",
|
||||
"attendee_user_ids": ["usr..."],
|
||||
"reminder_minutes": []
|
||||
}
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"state": "scheduled",
|
||||
"organizer": {
|
||||
"user_id": "usr...",
|
||||
"display_name": "..."
|
||||
},
|
||||
"participant_names": ["..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### 7.1 为什么不能从 manager 读取 manager secret
|
||||
|
||||
因为 `IM_CONFIG_API_SECRET` 是调用 manager 配置接口的 HMAC 引导凭证。
|
||||
|
||||
如果要读取 manager 配置接口,必须先有这个 secret 才能签名。
|
||||
所以它不能从同一个 manager 配置接口中获取,只能来自部署环境,例如 Kubernetes Secret。
|
||||
|
||||
### 7.2 为什么 service name 不是 im-server
|
||||
|
||||
`X-Service-Name` 表示调用方服务名。
|
||||
|
||||
- `im_server` 调 manager 时,应使用 `im-server`
|
||||
- `im_workbench` 调 manager 时,应使用 `im-workbench`
|
||||
|
||||
manager 侧应该允许多个合法调用方:
|
||||
|
||||
```text
|
||||
im-server,im-workbench
|
||||
```
|
||||
|
||||
### 7.3 为什么配置了 .env 还是没用
|
||||
|
||||
因为 Kubernetes 不会自动读取仓库 `.env`。
|
||||
|
||||
`.env` 只有在以下情况才会生效:
|
||||
|
||||
- 程序代码主动 `godotenv.Load()`
|
||||
- 启动脚本 `source .env`
|
||||
- docker compose 配了 `env_file`
|
||||
- CI/CD 将 `.env` 内容写进 Deployment 或 Secret
|
||||
|
||||
当前测试环境都不是这些方式。
|
||||
|
||||
### 7.4 只改 workbench 会怎样
|
||||
|
||||
如果只把 workbench 改成:
|
||||
|
||||
```text
|
||||
X-Service-Name: im-workbench
|
||||
```
|
||||
|
||||
但 manager 仍然只允许:
|
||||
|
||||
```text
|
||||
IM_CONFIG_API_SERVICE_NAME=im-server
|
||||
```
|
||||
|
||||
那么 workbench 调 manager 会返回 401。
|
||||
|
||||
所以需要 manager 和 workbench 一起配置。
|
||||
|
||||
## 8. 最终配置清单
|
||||
|
||||
manager:
|
||||
|
||||
```text
|
||||
Namespace: manager-system
|
||||
Secret: im-manager-secrets
|
||||
|
||||
IM_CONFIG_API_SERVICE_NAME=im-server,im-workbench
|
||||
IM_CONFIG_API_SECRET=<保持现有值>
|
||||
```
|
||||
|
||||
workbench:
|
||||
|
||||
```text
|
||||
Namespace: workbench-system
|
||||
Secret: im-workbench-secrets
|
||||
Key: workbench.json
|
||||
```
|
||||
|
||||
`workbench.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"manager": {
|
||||
"base_url": "https://admin.118im.com",
|
||||
"service_name": "im-workbench",
|
||||
"secret": "<manager 的 IM_CONFIG_API_SECRET>",
|
||||
"uid_key_group": "im",
|
||||
"uid_key_keys": [
|
||||
"uid_encryption_key",
|
||||
"UID_ENCRYPTION_KEY",
|
||||
"uid_key"
|
||||
],
|
||||
"timeout_ms": 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,先确认是否拉到最新提交。
|
||||
@@ -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: <api key>
|
||||
X-Request-ID: <request id>
|
||||
X-Device-ID: <optional 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 <token>
|
||||
```
|
||||
|
||||
退出成功后删除当前 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 或敏感数据密钥未正确配置 |
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
@@ -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 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 2 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 3 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 4 参考站点功能复刻原型
|
||||
|
||||
# 15 待确认事项
|
||||
|
||||
- 目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。
|
||||
- 任务来源、商品真实性、结算主体和佣金承担方。
|
||||
- 目标市场与适用法律、支付通道和实名要求。
|
||||
- 是否保留会员等级、推荐奖励、信用分和冻结机制。
|
||||
- 客服使用自建系统还是第三方服务,允许传输哪些用户数据。
|
||||
- 管理后台的组织结构、审批层级和财务对账责任人。
|
||||
- 品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。
|
||||
|
||||
在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。
|
||||
@@ -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` | 提交总经理信箱 |
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+23
-54
@@ -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()
|
||||
}
|
||||
|
||||
+92
-92
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-209
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.TenantID <= 0 {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"})
|
||||
return
|
||||
}
|
||||
st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := st.InitializeTenantDefaults(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{
|
||||
"tenant_id": body.TenantID,
|
||||
"workbench_apps": result.WorkbenchApps,
|
||||
"attendance_rules": result.AttendanceRules,
|
||||
"attendance_shifts": result.AttendanceShifts,
|
||||
"mailbox_configs": result.MailboxConfigs,
|
||||
"feedback_configs": result.FeedbackConfigs,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
var enabled *int
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
enabled = &value
|
||||
}
|
||||
}
|
||||
items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
case http.MethodPost:
|
||||
var body adminWorkbenchModuleRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{
|
||||
ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route,
|
||||
SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminWorkbenchModulePayload(*item))
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items))
|
||||
for _, item := range body.Items {
|
||||
items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder})
|
||||
}
|
||||
if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64)
|
||||
items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")),
|
||||
r.URL.Query().Get("employment_state"), userID)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminMailboxConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminMailboxConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminMailboxConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{
|
||||
Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields,
|
||||
MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminFeedbackConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminFeedbackConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminFeedbackConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{
|
||||
Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy,
|
||||
AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) {
|
||||
tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64)
|
||||
tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default")
|
||||
st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return nil, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
type adminWorkbenchModuleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconFileID string `json:"icon_file_id"`
|
||||
Route string `json:"route"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
UnreadKey string `json:"unread_key"`
|
||||
Enabled int `json:"enabled"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type adminMailboxConfigRequest struct {
|
||||
Enabled int `json:"enabled"`
|
||||
AllowAnonymous int `json:"allow_anonymous"`
|
||||
RequiredFields string `json:"required_fields"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
NoticeTitle string `json:"notice_title"`
|
||||
NoticeContent string `json:"notice_content"`
|
||||
}
|
||||
|
||||
type adminFeedbackConfigRequest struct {
|
||||
Categories string `json:"categories"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
AllowContact int `json:"allow_contact"`
|
||||
DiagnosticsNotice string `json:"diagnostics_notice"`
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, adminWorkbenchModulePayload(item))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any {
|
||||
return map[string]any{
|
||||
"id": item.ID,
|
||||
"code": item.Code,
|
||||
"name": item.Name,
|
||||
"iconFileId": item.IconFileID,
|
||||
"route": item.Route,
|
||||
"sortOrder": item.SortOrder,
|
||||
"unreadKey": item.UnreadKey,
|
||||
"enabled": item.Enabled,
|
||||
"permission": item.Permission,
|
||||
"createdAt": formatAdminTime(item.CreatedAt),
|
||||
"updatedAt": formatAdminTime(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"department_id": item.DepartmentID,
|
||||
"name": item.Name,
|
||||
"parent_id": item.ParentID,
|
||||
"child_count": item.ChildCount,
|
||||
"member_count": item.MemberCount,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"department_id": item.DepartmentID,
|
||||
"display_name": item.DisplayName,
|
||||
"position": item.Position,
|
||||
"mobile": item.Mobile,
|
||||
"email": item.Email,
|
||||
"avatar_file_id": item.AvatarFileID,
|
||||
"status": item.Status,
|
||||
"contactable": item.Contactable,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"enabled": cfg.Enabled,
|
||||
"allowAnonymous": cfg.AllowAnonymous,
|
||||
"requiredFields": cfg.RequiredFields,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"noticeTitle": cfg.NoticeTitle,
|
||||
"noticeContent": cfg.NoticeContent,
|
||||
"noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt),
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"categories": cfg.Categories,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"allowContact": cfg.AllowContact,
|
||||
"diagnosticsNotice": cfg.DiagnosticsNotice,
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatAdminTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+37
-1166
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestAttendanceCheckinPayloadReturnsJSONObjectFields(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 1, 27, 49, 0, time.UTC)
|
||||
payload := attendanceCheckinPayload(store.AttendanceCheckin{
|
||||
CheckinID: "attc_1",
|
||||
Action: "check_in",
|
||||
ClientTime: now,
|
||||
ServerTime: now,
|
||||
Result: "normal",
|
||||
Location: `{"name":"当前位置","latitude":22.929670335639365}`,
|
||||
Wifi: `{}`,
|
||||
Device: `{"platform":"ios","device_id":"C7A76C6A-FC57-4CED-B1C1-CE5199944F8C"}`,
|
||||
})
|
||||
|
||||
location, ok := payload["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location=%T, want map[string]any", payload["location"])
|
||||
}
|
||||
if location["name"] != "当前位置" {
|
||||
t.Fatalf("location=%v", location)
|
||||
}
|
||||
if _, ok := payload["wifi"].(map[string]any); !ok {
|
||||
t.Fatalf("wifi=%T, want map[string]any", payload["wifi"])
|
||||
}
|
||||
device, ok := payload["device"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("device=%T, want map[string]any", payload["device"])
|
||||
}
|
||||
if device["platform"] != "ios" {
|
||||
t.Fatalf("device=%v", device)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)<script\b[^>]*>.*?</script\s*>`)
|
||||
styleBlockPattern = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
|
||||
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()
|
||||
}
|
||||
@@ -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: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", 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: "<p>安全说明</p>"}})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package portal
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPositionInvalid = errors.New("banner position invalid")
|
||||
ErrContentKeyInvalid = errors.New("content key invalid")
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
var items []AIRecord
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT r.record_id,r.tenant_code,r.user_id,r.record_type,r.title,r.summary,r.status,r.detail_id,r.task_id,
|
||||
COALESCE(a.file_name, '') AS file_name,COALESCE(a.file_type, '') AS file_type,COALESCE(a.file_size, 0) AS file_size,
|
||||
r.created_at,r.updated_at,r.deleted_at
|
||||
FROM wb_ai_record r
|
||||
LEFT JOIN wb_ai_table_analysis a ON a.tenant_id=r.tenant_id AND a.record_id=r.detail_id
|
||||
WHERE `+where+` ORDER BY r.created_at DESC,r.record_id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
next = items[limit-1].RecordID
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, next, nil
|
||||
}
|
||||
|
||||
func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
return s.aiRecords(ctx, userID, recordType, queryText, cursor, limit)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAIRecord(ctx context.Context, userID int64, recordID string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_record SET status='deleted',deleted_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND record_id=? AND deleted_at IS NULL`,
|
||||
now, now, s.tenant(), formatInt64(userID), recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AITableAnalysis(ctx context.Context, userID int64, recordID string) (*AITableAnalysis, error) {
|
||||
var item AITableAnalysis
|
||||
err := s.db.GetContext(ctx, &item, `SELECT a.record_id,a.user_id,a.file_id,a.file_name,a.file_type,a.file_size,a.topic,
|
||||
COALESCE(CAST(a.metrics_json AS CHAR), '[]') AS metrics_json,
|
||||
COALESCE(CAST(a.preview_json AS CHAR), '{}') AS preview_json,
|
||||
COALESCE(CAST(a.alerts_json AS CHAR), '[]') AS alerts_json,
|
||||
a.interpretation,a.export_file_id,a.prompt_version,a.model,a.created_at,a.updated_at
|
||||
FROM wb_ai_table_analysis a
|
||||
JOIN wb_ai_record r ON r.tenant_id=a.tenant_id AND r.record_id=a.record_id
|
||||
WHERE a.tenant_id=? AND a.user_id=? AND a.record_id=? AND r.deleted_at IS NULL`,
|
||||
s.tenant(), formatInt64(userID), recordID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAIMeetingSummary(ctx context.Context, tenantCode string, userID int64, summary AIMeetingSummary) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_meeting_summary
|
||||
(summary_id,tenant_id,tenant_code,meeting_id,user_id,task_id,conclusions_json,decisions_json,action_items_json,
|
||||
source_file_ids_json,prompt_version,model,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
||||
ON DUPLICATE KEY UPDATE task_id=VALUES(task_id),conclusions_json=VALUES(conclusions_json),
|
||||
decisions_json=VALUES(decisions_json),action_items_json=VALUES(action_items_json),
|
||||
source_file_ids_json=VALUES(source_file_ids_json),prompt_version=VALUES(prompt_version),model=VALUES(model),
|
||||
version=version+1,updated_at=VALUES(updated_at)`,
|
||||
summary.SummaryID, s.tenant(), tenantCode, summary.MeetingID, formatInt64(userID), summary.TaskID,
|
||||
summary.ConclusionsJSON, summary.DecisionsJSON, summary.ActionItemsJSON, summary.SourceFileIDsJSON,
|
||||
summary.PromptVersion, summary.Model, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AIMeetingSummary(ctx context.Context, userID int64, meetingID string) (*AIMeetingSummary, error) {
|
||||
var item AIMeetingSummary
|
||||
err := s.db.GetContext(ctx, &item, `SELECT summary_id,meeting_id,user_id,task_id,
|
||||
COALESCE(CAST(conclusions_json AS CHAR), '[]') AS conclusions_json,
|
||||
COALESCE(CAST(decisions_json AS CHAR), '[]') AS decisions_json,
|
||||
COALESCE(CAST(action_items_json AS CHAR), '[]') AS action_items_json,
|
||||
COALESCE(CAST(source_file_ids_json AS CHAR), '[]') AS source_file_ids_json,
|
||||
prompt_version,model,edited_by,edited_at,version,created_at,updated_at
|
||||
FROM wb_ai_meeting_summary WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAIMeetingSummary(ctx context.Context, userID int64, meetingID, summaryID, conclusionsJSON, decisionsJSON, actionItemsJSON string) (*AIMeetingSummary, error) {
|
||||
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meeting.OrganizerID != userID && meeting.RecorderID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_meeting_summary SET conclusions_json=?,decisions_json=?,action_items_json=?,
|
||||
edited_by=?,edited_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND summary_id=?`,
|
||||
conclusionsJSON, decisionsJSON, actionItemsJSON, formatInt64(userID), now, now, s.tenant(), meetingID, summaryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.AIMeetingSummary(ctx, userID, meetingID)
|
||||
}
|
||||
|
||||
func (s *Store) aiRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
where := `tenant_id=? AND user_id=? AND deleted_at IS NULL`
|
||||
args := []any{s.tenant(), formatInt64(userID)}
|
||||
if recordType != "" {
|
||||
where += ` AND record_type=?`
|
||||
args = append(args, recordType)
|
||||
}
|
||||
if queryText != "" {
|
||||
where += ` AND (title LIKE ? OR summary LIKE ?)`
|
||||
like := "%" + queryText + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if cursor != "" {
|
||||
where += ` AND record_id<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
var items []AIRecord
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT record_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,
|
||||
created_at,updated_at,deleted_at FROM wb_ai_record WHERE `+where+` ORDER BY created_at DESC,record_id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
next = items[limit-1].RecordID
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, next, nil
|
||||
}
|
||||
|
||||
func marshalNullable(value any) (any, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func formatInt64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-1329
File diff suppressed because it is too large
Load Diff
@@ -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()+INTERVAL 1 DAY) AS today_count,
|
||||
(SELECT COALESCE(SUM(t.commission_amount),0) FROM hg_task_submission t WHERE t.tenant_id=? AND t.member_id=?
|
||||
AND t.submission_status=4 AND t.settled_at>=UTC_DATE() AND t.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(y.commission_amount),0) FROM hg_task_submission y WHERE y.tenant_id=? AND y.member_id=?
|
||||
AND y.submission_status=4 AND y.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND y.settled_at<UTC_DATE()) AS yesterday_commission_amount`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
return &row, err
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type MemberOverview 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"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
CreditScore int `db:"credit_score"`
|
||||
AvatarAttachmentID int64 `db:"avatar_attachment_id"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
LevelID int64 `db:"level_id"`
|
||||
LevelCode string `db:"level_code"`
|
||||
LevelName string `db:"level_name"`
|
||||
LevelIconURL string `db:"level_icon_url"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TotalIncome int64 `db:"total_income"`
|
||||
TotalExpense int64 `db:"total_expense"`
|
||||
Currency string `db:"currency"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
WalletVersion int `db:"wallet_version"`
|
||||
}
|
||||
|
||||
type AccountSecurity struct {
|
||||
PasswordHash string `db:"password_hash"`
|
||||
TradePasswordHash string `db:"trade_password_hash"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
}
|
||||
|
||||
type Avatar struct {
|
||||
AttachmentID int64 `db:"attachment_id"`
|
||||
URL string `db:"url"`
|
||||
}
|
||||
|
||||
func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*MemberOverview, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberOverview
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile_mask,
|
||||
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score,
|
||||
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
|
||||
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
|
||||
COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
|
||||
COALESCE(lv.commission_rate,0) AS commission_rate,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
|
||||
COALESCE(w.total_income,0) AS total_income,COALESCE(w.total_expense,0) AS total_expense,
|
||||
COALESCE(w.currency,'CNY') AS currency,COALESCE(w.account_status,1) AS wallet_status,
|
||||
COALESCE(w.version,0) AS wallet_version
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
|
||||
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*Avatar, 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()
|
||||
var activeMemberID int64
|
||||
err = tx.GetContext(ctx, &activeMemberID, `SELECT id FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var avatar Avatar
|
||||
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
|
||||
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
|
||||
(tenant_id,member_id,avatar_attachment_id,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0) ON DUPLICATE KEY UPDATE avatar_attachment_id=VALUES(avatar_attachment_id),updated_at=CURRENT_TIMESTAMP(3)`,
|
||||
tenantID, memberID, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &avatar, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountSecurity(ctx context.Context, tenantID, memberID int64) (*AccountSecurity, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row AccountSecurity
|
||||
err := s.db.GetContext(ctx, &row, `SELECT password_hash,trade_password_hash,member_status
|
||||
FROM hg_app_member WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLoginPassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTradePassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET trade_password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusMatching = 1
|
||||
orderStatusPending = 2
|
||||
orderStatusProcessing = 3
|
||||
orderStatusCompleted = 4
|
||||
orderStatusExpired = 5
|
||||
orderStatusFrozen = 6
|
||||
orderStatusCancelled = 7
|
||||
orderStatusFailed = 8
|
||||
)
|
||||
|
||||
type TaskHall struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"hall_code"`
|
||||
Name string `db:"name"`
|
||||
IconURL string `db:"icon_url"`
|
||||
Summary string `db:"summary"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
RateUnit string `db:"rate_unit"`
|
||||
ClientRoute string `db:"client_route"`
|
||||
}
|
||||
|
||||
type TaskEligibility struct {
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
HallEligible bool `db:"hall_eligible"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
PendingOrderID int64 `db:"pending_order_id"`
|
||||
}
|
||||
|
||||
type ClaimTaskInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
HallID int64
|
||||
MinimumAmount int64
|
||||
DeadlineAt time.Time
|
||||
OrderNo string
|
||||
}
|
||||
|
||||
type TaskOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
ProductID int64 `db:"product_id"`
|
||||
ProductName string `db:"product_name"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
Quantity int `db:"quantity"`
|
||||
UnitPriceAmount int64 `db:"unit_price_amount"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
Currency string `db:"currency"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||
Message string `db:"message"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
FreezeStatus int `db:"freeze_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
MatchedAt string `db:"matched_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
TaskTitle string `db:"task_title"`
|
||||
TaskDescription string `db:"task_description"`
|
||||
TaskDetailHTML string `db:"task_detail_html"`
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
SubmissionStatus int `db:"submission_status"`
|
||||
EvidenceText string `db:"evidence_text"`
|
||||
EvidenceIDs json.RawMessage `db:"evidence_attachment_ids"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
AllowResubmit int `db:"allow_resubmit"`
|
||||
SubmittedAt string `db:"submitted_at"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
SettledAt string `db:"settled_at"`
|
||||
}
|
||||
|
||||
type TaskOrderPage struct {
|
||||
Items []TaskOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
type TaskStats struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TodayCommissionAmount int64 `db:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount"`
|
||||
TodayClaimCount int `db:"today_claim_count"`
|
||||
TodayCompletedCount int `db:"today_completed_count"`
|
||||
}
|
||||
|
||||
type TaskSubmissionInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
}
|
||||
|
||||
type TaskSubmissionResult struct {
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
OrderStatus int
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]TaskHall, error) {
|
||||
rows := make([]TaskHall, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT h.id,h.hall_code,h.name,h.icon_url,h.summary,
|
||||
h.commission_rate,h.rate_unit,h.client_route FROM hg_task_hall h
|
||||
JOIN hg_app_member m ON m.tenant_id=h.tenant_id AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL
|
||||
JOIN hg_task_hall_level hl ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
||||
WHERE h.tenant_id=? AND h.publish_status=3 AND h.deleted_at IS NULL ORDER BY h.sort,h.id`, memberID, tenantID)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) {
|
||||
var row TaskEligibility
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,
|
||||
EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl
|
||||
ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
||||
WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.account_status,0) AS wallet_status,
|
||||
COALESCE(lv.daily_order_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=m.tenant_id AND o.member_id=m.id
|
||||
AND o.order_type=2 AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
|
||||
COALESCE((SELECT o2.id FROM hg_trade_order o2 WHERE o2.tenant_id=m.tenant_id AND o2.member_id=m.id
|
||||
AND o2.order_type=2 AND o2.order_status IN (1,2,3,6) ORDER BY o2.id DESC LIMIT 1),0) AS pending_order_id
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.deleted_at IS NULL LIMIT 1`, hallID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var member struct {
|
||||
LevelID int64 `db:"level_id"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hallCount int
|
||||
err = tx.GetContext(ctx, &hallCount, `SELECT COUNT(*) FROM hg_task_hall h
|
||||
JOIN hg_task_hall_level hl ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=?
|
||||
WHERE h.tenant_id=? AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL`, member.LevelID, input.TenantID, input.HallID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hallCount == 0 {
|
||||
return nil, ErrLevelNotEligible
|
||||
}
|
||||
var wallet struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
Status int `db:"account_status"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &wallet, `SELECT available_amount,account_status FROM hg_wallet_account
|
||||
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.Status != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.AvailableAmount < input.MinimumAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
var pending int64
|
||||
err = tx.GetContext(ctx, &pending, `SELECT id FROM hg_trade_order
|
||||
WHERE tenant_id=? AND member_id=? AND order_type=2 AND order_status IN (1,2,3,6)
|
||||
ORDER BY id DESC LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if pending > 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<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if todayCount >= 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()+INTERVAL 1 DAY)<t.daily_limit)
|
||||
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id
|
||||
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit)
|
||||
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := json.Marshal(map[string]any{
|
||||
"commission_amount": task.CommissionAmount,
|
||||
"commission_rate": task.CommissionRate,
|
||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
"member_level_id": member.LevelID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
||||
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
|
||||
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
||||
message,order_status,freeze_status,created_by,updated_by)
|
||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,1,1,0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||
member.LevelID, rules, "等待系统匹配")
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
orderID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "等待系统匹配",
|
||||
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||
where := ` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2`
|
||||
args := []any{tenantID, memberID}
|
||||
if status > 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) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(ys.commission_amount),0) FROM hg_task_submission ys WHERE ys.tenant_id=? AND ys.member_id=?
|
||||
AND ys.submission_status=4 AND ys.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND ys.settled_at<UTC_DATE()) AS yesterday_commission_amount,
|
||||
(SELECT COUNT(*) FROM hg_trade_order tc WHERE tc.tenant_id=? AND tc.member_id=?
|
||||
AND tc.order_type=2 AND tc.created_at>=UTC_DATE() AND tc.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_claim_count,
|
||||
(SELECT COUNT(*) FROM hg_trade_order cc WHERE cc.tenant_id=? AND cc.member_id=? AND cc.order_type=2 AND cc.order_status=4
|
||||
AND cc.completed_at>=UTC_DATE() AND cc.completed_at<UTC_DATE()+INTERVAL 1 DAY) AS today_completed_count
|
||||
FROM hg_app_member m LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*TaskSubmissionResult, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var order struct {
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
|
||||
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.OrderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if order.DeadlineAt != "" {
|
||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
return nil, commitErr
|
||||
}
|
||||
return nil, ErrOrderExpired
|
||||
}
|
||||
}
|
||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, err := json.Marshal(input.AttachmentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
|
||||
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
||||
}
|
||||
|
||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
|
||||
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := tx.GetContext(ctx, &count, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(ids) {
|
||||
return ErrEvidenceInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, businessNumbers []string) ([]map[string]any, error) {
|
||||
if len(businessNumbers) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
|
||||
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger
|
||||
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
item := map[string]any{}
|
||||
if err := rows.MapScan(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range item {
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
item[key] = string(bytes)
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TenantInitializationResult reports how many missing defaults were created.
|
||||
// Existing tenant customizations are deliberately left untouched.
|
||||
type TenantInitializationResult struct {
|
||||
WorkbenchApps int64
|
||||
AttendanceRules int64
|
||||
AttendanceShifts int64
|
||||
MailboxConfigs int64
|
||||
FeedbackConfigs int64
|
||||
}
|
||||
|
||||
// InitializeTenantDefaults creates the minimum workbench data required by a tenant.
|
||||
// Tenant-scoped unique indexes make this operation safe to retry.
|
||||
func (s *Store) InitializeTenantDefaults(ctx context.Context) (*TenantInitializationResult, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
tenantID := s.tenant()
|
||||
result := &TenantInitializationResult{}
|
||||
|
||||
apps := []struct {
|
||||
code, name, route string
|
||||
sort int
|
||||
}{
|
||||
{code: "attendance", name: "打卡", route: "/attendance", sort: 10},
|
||||
{code: "monthly_reports", name: "月报", route: "/reports/monthly", sort: 20},
|
||||
{code: "daily_reports", name: "日报", route: "/reports/daily", sort: 30},
|
||||
{code: "organization", name: "部门框架", route: "/organization", sort: 40},
|
||||
{code: "executive_mailbox", name: "举报信箱", route: "/executive-mailbox", sort: 50},
|
||||
{code: "meetings", name: "会议通知", route: "/meetings", sort: 60},
|
||||
{code: "meeting_notes", name: "会议笔记", route: "/meeting-notes", sort: 70},
|
||||
}
|
||||
for _, app := range apps {
|
||||
res, execErr := tx.ExecContext(ctx, `INSERT INTO im_workbench_apps
|
||||
(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_workbench_apps WHERE tenant_id=? AND code=?
|
||||
)`,
|
||||
tenantID, app.code, app.name, "", app.route, app.sort, "", 1, "enabled", now, now,
|
||||
tenantID, app.code)
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
result.WorkbenchApps += rows
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO im_attendance_rules
|
||||
(tenant_id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,effective_from,effective_to,state,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,CURDATE(),NULL,'active',?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_attendance_rules WHERE tenant_id=? AND rule_id=?
|
||||
)`,
|
||||
tenantID, "att_rule_default", "默认考勤规则", "1,2,3,4,5", 0, 0, 1, 1, now, now,
|
||||
tenantID, "att_rule_default")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.AttendanceRules, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_shifts
|
||||
(tenant_id,shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,state,sort_order,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_attendance_shifts WHERE tenant_id=? AND shift_id=?
|
||||
)`,
|
||||
tenantID, "shift_default", "att_rule_default", "默认班次", "09:00:00", "18:00:00", "08:00:00", "19:00:00", "active", 10, now, now,
|
||||
tenantID, "shift_default")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.AttendanceShifts, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_mailbox_config
|
||||
(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||
SELECT ?,1,1,?,5000,?,'总经理信箱','请如实填写诉求。',?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_mailbox_config WHERE tenant_id=?
|
||||
)`,
|
||||
tenantID, `["subject","content"]`, `{"max_files":5,"purpose":"executive_mailbox"}`, now, now, now,
|
||||
tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.MailboxConfigs, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_feedback_config
|
||||
(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||
SELECT ?,?,2000,?,1,'',?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_feedback_config WHERE tenant_id=?
|
||||
)`,
|
||||
tenantID, `["bug","suggestion","complaint","other"]`, `{"max_files":5,"purpose":"feedback"}`, now, now,
|
||||
tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.FeedbackConfigs, _ = res.RowsAffected()
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type WorkbenchModule struct {
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
IconFileID string `db:"icon_file_id"`
|
||||
Route string `db:"route"`
|
||||
SortOrder int `db:"sort_order"`
|
||||
UnreadCount int `db:"unread_count"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Permission string `db:"permission"`
|
||||
}
|
||||
|
||||
type OrganizationDepartment struct {
|
||||
DepartmentID string `db:"department_id"`
|
||||
Name string `db:"name"`
|
||||
ParentID string `db:"parent_id"`
|
||||
ChildCount int `db:"child_count"`
|
||||
MemberCount int `db:"member_count"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type OrganizationMember struct {
|
||||
UserID int64 `db:"user_id"`
|
||||
DepartmentID string `db:"department_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
Position string `db:"position"`
|
||||
Mobile string `db:"mobile"`
|
||||
Email string `db:"email"`
|
||||
AvatarFileID string `db:"avatar_file_id"`
|
||||
Status string `db:"status"`
|
||||
Contactable bool `db:"contactable"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type UserSummary struct {
|
||||
UserID int64
|
||||
DisplayName string
|
||||
AvatarFileID string
|
||||
}
|
||||
|
||||
type AttendanceRule struct {
|
||||
RuleID string `db:"rule_id"`
|
||||
Name string `db:"name"`
|
||||
Workday string `db:"workday"`
|
||||
LocationRequired bool `db:"location_required"`
|
||||
WifiRequired bool `db:"wifi_required"`
|
||||
AllowOutsideCheckin bool `db:"allow_outside_checkin"`
|
||||
AppealAllowed bool `db:"appeal_allowed"`
|
||||
AppealDeadline *time.Time `db:"appeal_deadline"`
|
||||
}
|
||||
|
||||
type AttendanceShift struct {
|
||||
ShiftID string `db:"shift_id"`
|
||||
RuleID string `db:"rule_id"`
|
||||
Name string `db:"name"`
|
||||
WorkStart string `db:"work_start"`
|
||||
WorkEnd string `db:"work_end"`
|
||||
CheckinStart string `db:"checkin_start"`
|
||||
CheckoutEnd string `db:"checkout_end"`
|
||||
}
|
||||
|
||||
type AttendanceCheckin struct {
|
||||
CheckinID string `db:"checkin_id"`
|
||||
Action string `db:"action"`
|
||||
ClientTime time.Time `db:"client_time"`
|
||||
ServerTime time.Time `db:"server_time"`
|
||||
Result string `db:"result"`
|
||||
Location string `db:"location"`
|
||||
Wifi string `db:"wifi"`
|
||||
Device string `db:"device"`
|
||||
}
|
||||
|
||||
type AttendanceRecord struct {
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Date time.Time `db:"record_date"`
|
||||
RuleID string `db:"rule_id"`
|
||||
ShiftID string `db:"shift_id"`
|
||||
Status string `db:"status"`
|
||||
Exceptions string `db:"exceptions"`
|
||||
AppealStatus string `db:"appeal_status"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Checkins []AttendanceCheckin
|
||||
}
|
||||
|
||||
type AttendanceConfig struct {
|
||||
Date time.Time
|
||||
Timezone string
|
||||
Rule AttendanceRule
|
||||
Shifts []AttendanceShift
|
||||
TodayRecord *AttendanceRecord
|
||||
}
|
||||
|
||||
type AttendanceStats struct {
|
||||
ExpectedCount int
|
||||
CheckedCount int
|
||||
ExceptionCount int
|
||||
NotCheckedCount int
|
||||
}
|
||||
|
||||
type AttendanceAppeal struct {
|
||||
AppealID string `db:"appeal_id"`
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
AppealType string `db:"appeal_type"`
|
||||
Reason string `db:"reason"`
|
||||
TargetAction string `db:"target_action"`
|
||||
TargetTime *time.Time `db:"target_time"`
|
||||
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||
Status string `db:"status"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type Meeting struct {
|
||||
MeetingID string `db:"meeting_id"`
|
||||
OrganizerID int64 `db:"organizer_id"`
|
||||
RecorderID int64 `db:"recorder_user_id"`
|
||||
Title string `db:"title"`
|
||||
StartAt time.Time `db:"start_at"`
|
||||
EndAt time.Time `db:"end_at"`
|
||||
Location string `db:"location"`
|
||||
State string `db:"state"`
|
||||
ReminderMinutes string `db:"reminder_minutes"`
|
||||
IdempotencyKey string `db:"idempotency_key"`
|
||||
Version int `db:"version"`
|
||||
CanceledAt *time.Time `db:"canceled_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Organizer UserSummary
|
||||
Recorder UserSummary
|
||||
Participants []MeetingParticipant
|
||||
}
|
||||
|
||||
type MeetingParticipant struct {
|
||||
MeetingID string `db:"meeting_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Response string `db:"response"`
|
||||
ResponseAt *time.Time `db:"response_at"`
|
||||
User UserSummary
|
||||
}
|
||||
|
||||
type MeetingNoteFile struct {
|
||||
NoteFileID string `db:"note_file_id"`
|
||||
MeetingID string `db:"meeting_id"`
|
||||
FileID string `db:"file_id"`
|
||||
UploaderID int64 `db:"uploader_id"`
|
||||
Uploader UserSummary
|
||||
FileName string `db:"file_name"`
|
||||
MIMEType string `db:"mime_type"`
|
||||
FileSize int64 `db:"file_size"`
|
||||
FileETag string `db:"file_etag"`
|
||||
Status string `db:"status"`
|
||||
PreviewStatus string `db:"preview_status"`
|
||||
PreviewMode string `db:"preview_mode"`
|
||||
PreviewFileID string `db:"preview_file_id"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type MeetingNoteFileInput struct {
|
||||
FileID string
|
||||
FileName string
|
||||
MIMEType string
|
||||
FileSize *int64
|
||||
FileETag string
|
||||
}
|
||||
|
||||
type DailyReport struct {
|
||||
ReportID string `db:"report_id"`
|
||||
AuthorID int64 `db:"author_id"`
|
||||
ReportDate time.Time `db:"report_date"`
|
||||
Content string `db:"content"`
|
||||
Attachments string `db:"attachments"`
|
||||
Status string `db:"status"`
|
||||
ReadState string `db:"read_state"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt *time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type MonthlyReport struct {
|
||||
ReportID string `db:"report_id"`
|
||||
AuthorID int64 `db:"author_id"`
|
||||
Month string `db:"report_month"`
|
||||
WorkSummary string `db:"work_summary"`
|
||||
NextPlan string `db:"next_plan"`
|
||||
Attachments string `db:"attachments"`
|
||||
Status string `db:"status"`
|
||||
ReadState string `db:"read_state"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt *time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ReportStats struct {
|
||||
ExpectedCount int
|
||||
SubmittedCount int
|
||||
MissingCount int
|
||||
DepartmentID string
|
||||
}
|
||||
|
||||
type MailboxConfig struct {
|
||||
Enabled bool `db:"enabled"`
|
||||
AllowAnonymous bool `db:"allow_anonymous"`
|
||||
RequiredFields string `db:"required_fields"`
|
||||
MaxLength int `db:"max_length"`
|
||||
AttachmentPolicy string `db:"attachment_policy"`
|
||||
NoticeTitle string `db:"notice_title"`
|
||||
NoticeContent string `db:"notice_content"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type MailboxSubmission struct {
|
||||
SubmissionID string `db:"submission_id"`
|
||||
SenderID int64 `db:"sender_id"`
|
||||
Subject string `db:"subject"`
|
||||
Content string `db:"content"`
|
||||
Anonymous bool `db:"anonymous"`
|
||||
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||
NoticeVersion string `db:"notice_version"`
|
||||
Status string `db:"status"`
|
||||
SubmittedAt time.Time `db:"submitted_at"`
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type PaymentAccount struct {
|
||||
ID int64 `db:"id"`
|
||||
AccountType int `db:"account_type"`
|
||||
BankCode string `db:"bank_code"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||
VerifyStatus int `db:"verify_status"`
|
||||
IsDefault int `db:"is_default"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
}
|
||||
|
||||
type PaymentAccountCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
MobileCipher string
|
||||
MobileMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
QRAttachmentID int64
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"channel_code"`
|
||||
Name string `db:"name"`
|
||||
ChannelType int `db:"channel_type"`
|
||||
MinAmount int64 `db:"min_amount"`
|
||||
MaxAmount int64 `db:"max_amount"`
|
||||
FeeType int `db:"fee_type"`
|
||||
FeeFixedAmount int64 `db:"fee_fixed_amount"`
|
||||
FeeRate int `db:"fee_rate"`
|
||||
}
|
||||
|
||||
type WithdrawalWallet struct {
|
||||
AccountID int64 `db:"account_id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
AccountStatus int `db:"account_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
}
|
||||
|
||||
type WithdrawalCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
WithdrawalNo string
|
||||
PaymentAccountID int64
|
||||
ExpectedAccountType int
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedMobileCipher string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
ActualAmount int64
|
||||
Currency string
|
||||
AccountSnapshotCipher string
|
||||
AccountMask string
|
||||
BankName string
|
||||
HolderMask string
|
||||
AvailableLedgerNo string
|
||||
FrozenLedgerNo string
|
||||
}
|
||||
|
||||
type WithdrawalOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
WithdrawalNo string `db:"withdrawal_no"`
|
||||
PaymentAccountID int64 `db:"payment_account_id"`
|
||||
ChannelID int64 `db:"channel_id"`
|
||||
ChannelName string `db:"channel_name"`
|
||||
RequestAmount int64 `db:"request_amount"`
|
||||
FeeAmount int64 `db:"fee_amount"`
|
||||
ActualAmount int64 `db:"actual_amount"`
|
||||
Currency string `db:"currency"`
|
||||
AccountMask string `db:"account_mask"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
RiskResult json.RawMessage `db:"risk_result"`
|
||||
Status int `db:"withdrawal_status"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
ExternalNo string `db:"external_no"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
ProcessedAt string `db:"processed_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
Replayed bool `db:"-"`
|
||||
}
|
||||
|
||||
type WithdrawalPage struct {
|
||||
Items []WithdrawalOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
items := make([]PaymentAccount, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
|
||||
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 deleted_at IS NULL
|
||||
ORDER BY is_default ASC,id DESC`, tenantID, memberID)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var memberID int64
|
||||
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.QRAttachmentID > 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<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency='CNY' AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation) (*WithdrawalOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var memberID int64
|
||||
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, findErr := withdrawalByNo(ctx, tx, input.TenantID, input.MemberID, input.WithdrawalNo); findErr == nil {
|
||||
if existing.PaymentAccountID != input.PaymentAccountID || existing.RequestAmount != input.RequestAmount || existing.Currency != input.Currency {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
existing.Replayed = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(findErr, ErrWithdrawalNotFound) {
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
var account PaymentAccount
|
||||
err = tx.GetContext(ctx, &account, `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,'' AS created_at,'' AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
|
||||
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrPaymentAccountInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
||||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
||||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
|
||||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
err = tx.GetContext(ctx, &channel, `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 id=?
|
||||
AND direction IN (2,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.Channel.ID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel != input.Channel {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 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<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.Currency)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.AccountStatus != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.DailyLimit > 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user