feat: migrate workbench services

This commit is contained in:
richjohnrambo
2026-10-10 23:34:06 +08:00
parent c13fb72849
commit 81e3cf1d03
88 changed files with 7358 additions and 15333 deletions
+1 -7
View File
@@ -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
+1 -1
View File
@@ -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
+49 -125
View File
@@ -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)。
+1 -1
View File
@@ -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
}
+8 -48
View File
@@ -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
View File
@@ -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
-230
View File
@@ -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
}
}
```
-351
View File
@@ -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,先确认是否拉到最新提交。
+101
View File
@@ -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](移动端任务与账户平台需求文档.assets/figure-01.png)
图 1 参考站点功能复刻原型
![低保真原型图 2](移动端任务与账户平台需求文档.assets/figure-02.png)
图 2 参考站点功能复刻原型
![低保真原型图 3](移动端任务与账户平台需求文档.assets/figure-03.png)
图 3 参考站点功能复刻原型
![低保真原型图 4](移动端任务与账户平台需求文档.assets/figure-04.png)
图 4 参考站点功能复刻原型
# 15 待确认事项
- 目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。
- 任务来源、商品真实性、结算主体和佣金承担方。
- 目标市场与适用法律、支付通道和实名要求。
- 是否保留会员等级、推荐奖励、信用分和冻结机制。
- 客服使用自建系统还是第三方服务,允许传输哪些用户数据。
- 管理后台的组织结构、审批层级和财务对账责任人。
- 品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。
在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。
-56
View File
@@ -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` | 提交总经理信箱 |
-16
View File
@@ -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
)
-56
View File
@@ -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=
+123
View File
@@ -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)
}
}
+460
View File
@@ -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"
}
+100
View File
@@ -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)
}
}
}
-197
View File
@@ -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"`
}
-560
View File
@@ -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")
}
-54
View File
@@ -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
View File
@@ -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
View File
@@ -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 ""
}
+27
View File
@@ -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
View File
@@ -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))
+9 -88
View File
@@ -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")
}
}
-180
View File
@@ -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)
}
+19
View File
@@ -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")
)
+737
View File
@@ -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
}
+128
View File
@@ -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)
}
}
+144
View File
@@ -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)
}
}
-392
View File
@@ -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
-254
View File
@@ -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)
}
-55
View File
@@ -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)
}
}
}
-366
View File
@@ -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
View File
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)
}
}
-9
View File
@@ -1,9 +0,0 @@
package httpapi
import "errors"
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict")
)
+201
View File
@@ -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)
}
}
-72
View File
@@ -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
}
-51
View File
@@ -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())
}
}
+104
View File
@@ -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)
}
}
-547
View File
@@ -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")
}
-171
View File
@@ -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")
}
+139
View File
@@ -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)
}
}
+160
View File
@@ -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)
}
}
+19 -49
View File
@@ -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)
}
+231
View File
@@ -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)
}
}
-142
View File
@@ -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
}
-47
View File
@@ -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")
}
}
+10
View File
@@ -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")
)
+149
View File
@@ -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()
}
+61
View File
@@ -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)
}
}
-235
View File
@@ -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
}
-83
View File
@@ -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))))
}
+8
View File
@@ -0,0 +1,8 @@
package portal
import "errors"
var (
ErrPositionInvalid = errors.New("banner position invalid")
ErrContentKeyInvalid = errors.New("content key invalid")
)
+249
View File
@@ -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"
}
+128
View File
@@ -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)
}
}
}
+175
View File
@@ -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)
}
-306
View File
@@ -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
}
-603
View File
@@ -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
}
-116
View File
@@ -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
}
-23
View File
@@ -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)
}
})
}
}
+28 -4
View File
@@ -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")
)
+78
View File
@@ -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`
}
-94
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -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
}
+161
View File
@@ -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
}
+480
View File
@@ -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()
}
-115
View File
@@ -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
}
-234
View File
@@ -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"`
}
+477
View File
@@ -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
}
-262
View File
@@ -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
}
-49
View File
@@ -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)
}
}
+14
View File
@@ -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")
)
+553
View File
@@ -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))
}
+117
View File
@@ -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)
}
}
-152
View File
@@ -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"