feat: 初始化营销系统数据工具项目

- 添加基础项目结构,包括后端Go代码和前端静态文件
- 实现核心功能模块:数据导出、模板管理、元数据查询
- 添加多数据源支持(营销系统、易码通、元数据库)
- 实现CSV和Excel导出功能
- 添加配置管理系统,支持YAML和环境变量
- 实现日志记录和请求追踪
- 添加Docker部署支持
- 编写README文档说明项目结构和启动方式
This commit is contained in:
zhouyonggao 2025-12-02 15:48:17 +08:00
commit 113a8ffa0a
53 changed files with 8238 additions and 0 deletions

246
.cursorrules Normal file
View File

@ -0,0 +1,246 @@
# MarketingSystemDataTool 项目规则
## 项目概述
这是一个营销系统和易码通数据工具,提供数据导出、模板管理、元数据查询等功能。
### 技术栈
- **后端**: Go 1.21,使用标准库 `net/http`,不使用第三方 Web 框架
- **前端**: Vue 3 (通过 CDN 引入) + Element Plus
- **数据库**: MySQL支持多数据源Marketing、YMT、Meta
- **导出格式**: CSV、Excel (使用 excelize/v2)
- **配置**: YAML 配置文件 + 环境变量覆盖
## 项目结构
```
server/ # Go 后端代码
├── cmd/server/ # 主程序入口
├── internal/ # 内部包
│ ├── api/ # HTTP 路由和处理器
│ ├── config/ # 配置管理
│ ├── db/ # 数据库连接和池管理
│ ├── exporter/ # 数据导出逻辑
│ ├── logging/ # 日志工具
│ ├── models/ # 数据模型
│ ├── repo/ # 数据仓库层
│ ├── schema/ # 数据库模式定义
│ └── ymtcrypto/ # 加密工具
web/ # 前端静态文件
config/ # 非敏感配置
scripts/ # 开发和运维脚本
```
## Go 代码规范
### 架构模式
1. **Handler 函数模式**: 每个 API 端点使用 `Handler` 函数返回 `http.Handler`
```go
func ExportsHandler(meta, marketing, ymt *sql.DB) http.Handler {
api := &ExportsAPI{meta: meta, marketing: marketing, ymt: ymt}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 路由逻辑
})
}
```
2. **中间件链**: 使用函数式中间件,顺序为 `withAccess` -> `withTrace` -> Handler
```go
mux.Handle("/api/exports", withAccess(withTrace(ExportsHandler(...))))
```
3. **Context 传递**: 使用 `context.Context` 传递 trace_id、SQL、请求元数据等
- `TraceIDFrom(r)` - 获取 trace_id
- `WithSQL(r, sql)` - 设置 SQL 到 context
- `SQLFrom(r)` - 从 context 获取 SQL
- `MetaFrom(r)` - 获取请求元数据
### 代码风格
1. **标准库优先**: 优先使用 Go 标准库,避免引入不必要的第三方依赖
2. **包命名**: 使用小写单数形式,如 `api`, `config`, `db`
3. **函数命名**:
- Handler 函数使用 `XxxHandler` 命名
- 中间件函数使用 `withXxx` 命名
- 工具函数使用驼峰命名
4. **错误处理**:
- API 错误使用 `fail(w, r, status, msg)` 返回统一格式
- 成功响应使用 `ok(w, r, data)` 返回统一格式
- 启动错误使用 `log.Fatal()`
5. **数据库操作**:
- 使用 `database/sql` 标准库
- 支持连接池配置(通过环境变量)
- 多数据库实例:`metaDB` (模板/任务), `marketingDB` (营销数据), `ymtDB` (易码通数据)
### 响应格式
统一使用以下 JSON 响应结构:
```go
type resp struct {
Code int `json:"code"` // 0 表示成功,非 0 表示错误
Msg string `json:"msg"` // 消息
Data interface{} `json:"data"` // 数据
TraceID string `json:"trace_id"` // 追踪 ID
}
```
### 日志规范
1. **结构化日志**: 使用 `logging.JSON()` 记录结构化日志
2. **访问日志**: 在 `withAccess` 中间件中自动记录,包含 method、path、status、duration 等
3. **错误日志**: 在 `fail()` 函数中记录错误,包含 trace_id、文件位置、SQL如果有
4. **日志格式**: JSON 格式,包含 level、ts、trace_id、method、path 等字段
## API 设计规范
### 路由规则
1. **RESTful 风格**:
- `GET /api/templates` - 列表
- `POST /api/templates` - 创建
- `GET /api/templates/{id}` - 详情
- `PATCH /api/templates/{id}` - 更新
- `DELETE /api/templates/{id}` - 删除
2. **路径处理**: 使用 `strings.TrimPrefix()` 处理路径前缀,支持带或不带尾部斜杠
3. **CORS 支持**: 所有 API 通过 `withAccess` 中间件自动处理 CORS
### 请求处理
1. **参数解析**:
- GET 请求从 `r.URL.Query()` 获取参数
- POST 请求从 `r.Body` 读取 JSON
- 使用 `json.Decoder` 或 `json.Unmarshal` 解析
2. **用户身份**: 通过 `userId` 查询参数传递(支持 `userId`, `userid`, `user_id`
3. **权限检查**: 在 Handler 内部根据业务逻辑进行权限验证
## 数据库操作规范
### 连接管理
1. **多数据源**:
- `metaDB`: 存储模板和任务元数据
- `marketingDB`: 营销系统数据
- `ymtDB`: 易码通数据
2. **连接池配置**: 通过环境变量配置(`YMT_DB_*`, `MARKETING_DB_*`, `YMT_TEST_DB_*`
3. **DSN 格式**: 使用 `DB.DSN()` 方法生成,包含 `parseTime=True&loc=Local&charset=utf8mb4`
### Schema 抽象
1. **Schema 接口**: 使用 `schema.Schema` 接口抽象不同数据源的差异
2. **字段映射**: 通过 `MapField()` 方法将逻辑字段名映射到物理字段名
3. **表名映射**: 通过 `TableName()` 方法获取实际表名
4. **JOIN 构建**: 通过 `BuildJoins()` 方法构建必要的 JOIN 语句
### SQL 构建
1. **SQL Builder**: 使用 `exporter.BuildSQL()` 构建查询 SQL
2. **白名单机制**: 字段访问需要通过白名单验证
3. **参数化查询**: 使用 `?` 占位符,防止 SQL 注入
4. **时间过滤**: 营销系统的 order 表必须提供 `create_time_between` 过滤条件
## 配置管理规范
### 配置文件
1. **配置文件位置**:
- 优先使用 `server/config.yaml`
- 备选 `config.yaml`
2. **配置结构**:
```yaml
app:
port: "8077"
marketing_db:
host: "..."
port: "..."
user: "..."
password: "..."
name: "..."
ymt_db: {...}
ymt_test_db: {...}
ymt_key_decrypt_key_b64: "..."
```
3. **环境变量覆盖**:
- 支持通过环境变量覆盖配置
- 环境变量优先级高于配置文件
- 支持 `.env.local` 文件(不提交到版本控制)
## 前端代码规范
### Vue 3 使用
1. **CDN 引入**: 使用 CDN 方式引入 Vue 3 和 Element Plus不打包
2. **组合式 API**: 使用 `setup()` 和 `reactive()` 进行状态管理
3. **API 调用**: 使用 `fetch()` 调用后端 APIbase URL 为 `http://localhost:8077`
4. **用户 ID**: 从 URL 查询参数获取 `userId`
### 代码组织
1. **单文件应用**: 主要逻辑在 `web/main.js` 中
2. **样式分离**: 样式在 `web/styles.css` 中
3. **静态资源**: 第三方库在 `web/vendor/` 目录
## 错误处理规范
1. **API 错误**:
- HTTP 状态码400 (Bad Request), 404 (Not Found), 500 (Internal Server Error)
- 响应体:`{code: 1, msg: "错误消息", data: null, trace_id: "..."}`
2. **日志记录**:
- 所有错误都记录到日志,包含 trace_id、文件位置、SQL如果有
- 使用 `failCat()` 记录分类错误
3. **错误消息**: 使用中文错误消息,清晰描述问题
## 导出功能规范
1. **导出格式**: 支持 CSV 和 Excel (xlsx)
2. **流式处理**: 使用 `exporter.Stream()` 进行流式导出,避免内存溢出
3. **文件存储**: 导出文件存储在 `storage/export/` 目录
4. **异步处理**: 导出任务异步执行,通过 job 状态跟踪进度
## 安全规范
1. **访问控制**: 通过 `withAccess` 中间件处理 CORS
2. **SQL 注入防护**: 使用参数化查询,不拼接 SQL 字符串
3. **字段白名单**: 字段访问必须通过白名单验证
4. **敏感信息**: 密码等敏感信息不记录到日志
## 开发建议
1. **新增 API**:
- 在 `internal/api/` 下创建新的 handler 文件
- 在 `router.go` 中注册路由
- 使用统一的响应格式和错误处理
2. **新增数据源**:
- 在 `internal/schema/` 下实现 `Schema` 接口
- 在 `schema.Get()` 中添加路由逻辑
3. **代码审查要点**:
- 是否使用参数化查询
- 是否通过字段白名单验证
- 是否使用统一的响应格式
- 是否记录必要的日志
4. **性能优化**:
- 大数据量导出使用流式处理
- 合理使用数据库连接池
- 避免 N+1 查询问题
## 注意事项
1. **不要使用第三方 Web 框架**: 坚持使用标准库 `net/http`
2. **保持代码简洁**: 避免过度抽象,保持代码可读性
3. **统一错误处理**: 所有错误都通过 `fail()` 函数返回
4. **日志完整性**: 确保关键操作都有日志记录
5. **配置灵活性**: 支持配置文件和环境变量两种方式

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git
log/
storage/
server/bin/
server/config.yaml
server/config.test.yaml
server/config.prod.yaml
scripts/*.tar

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
server/log/
storage/
server/config.test.yaml
server/config.prod.yaml
server/config.yaml
server/bin/
scripts/*.tar

View File

@ -0,0 +1,651 @@
# MarketingSystemDataTool 项目规则
## 1. 项目概述
- 技术栈:后端使用 `Go`,前端使用 `Vue 3 + Element Plus`(通过 CDN 引入,无打包);前后端代码同仓管理。
- 目标:为“营销系统”和“易码通系统”提供统一的高性能数据导出能力,支持模板化 SQL 构建与权限控制,生成 `CSV``Excel` 文件。
- 数据源:至少包含两个独立库(`marketing_db`、`ymt_db`),通过环境变量配置连接与凭据。
## 2. 目录结构与命名约定
- `server/`Go 服务端代码API、模板校验、导出执行、权限校验、日志与监控
- `web/`:前端页面与静态资源(模板管理、导出发起、进度查看、历史下载),采用 `Vue 3 + Element Plus` 组件搭建。
- `config/`:非敏感配置(字段白名单、场景定义、关联关系元数据)。敏感信息使用环境变量注入。
- `scripts/`:开发与运维脚本(如生成索引建议、批量校验 EXPLAIN
- 命名规范:统一使用下划线或小驼峰,文件名见名知意;避免缩写导致歧义。
## 3. 权限与安全
- 权限字段:所有导出查询必须包含权限范围条件(如 `user_id`、`tenant_id`、`owner_id`),通过 `IN` 或等值过滤传入。
- 白名单策略:仅允许查询白名单字段与表;禁止 `SELECT *` 与任意动态 SQL 拼接。
- 参数化查询:所有条件、排序、分页使用预编译参数;禁止字符串拼接导致注入风险。
- 敏感信息:数据库凭据、密钥等仅通过环境变量加载;严禁写入仓库与日志。
- 审计日志记录模板创建与变更、EXPLAIN 结果、导出执行参数、数据量、耗时、下载行为。
## 4. 导出场景定义
- 易码通系统:
- 订单导出:订单类型包含 `直充卡密订单`、`立减金订单`、`红包订单`。
- 支付记录导出:支付类型包含 `key码支付`、`商品支付`。
- 营销系统:
- 订单导出:订单类型包含 `直充卡密订单`、`立减金订单`、`红包订单`。
- 主表定义:每个场景明确主表与主键、权限字段、必要索引;关联表通过元数据预定义关联键与可选字段。
## 5. SQL 模板规范
- 模板组成:
- 选择数据源(库名)。
- 选择数据类型(主表)。
- 选择关联数据(关联表、连接类型、连接键)。
- 字段选择(仅白名单字段,支持别名)。
- 条件过滤(权限条件必填,业务条件可选)。
- 排序与分页(必须与索引一致性,避免大偏移)。
- 输出格式(`csv`/`xlsx`),列统计与多 sheet 选项。
- 生成规则:
- 自动生成 `JOIN` 与选择列,强制添加权限过滤。
- 禁止笛卡尔积;必须给出明确连接条件。
- 大表查询优先走索引覆盖;必要时使用 `CTE/子查询` 减少回表。
- 统一走 `EXPLAIN` 评估;达标才允许执行。
## 6. EXPLAIN 评估标准
- 基本要求:
- 禁止对大表 `type=ALL` 全表扫描;优先 `ref`/`range`/`const`。
- `rows` 估算在权限范围内合理可控;避免千万级一次性拉取。
- 正确使用连接顺序与驱动表;避免 `Using temporary`、`Using filesort` 发生在超大数据集。
- 必要索引存在(权限字段、连接键、排序键、过滤键)。
- 超标处理:
- 自动生成索引建议(基于过滤列与排序列)。
- 指导模板调整(减少字段、改用预聚合或分批)。
## 7. 导出执行与进度
- 执行模型:
- 单次查询尽量覆盖需求,避免多次往返;必要时拆分批次流式写入。
- 流式导出:服务端边拉取边写文件,控制内存与背压。
- 批大小动态:根据 `rows` 估算与 I/O 速率自适应。
- 进度追踪:
- 记录总行数(估算与实际)、已写行数、当前批次、速率、剩余时间估算。
- 状态:`queued`/`running`/`failed`/`completed`/`canceled`。
- 历史下载与导出记录:模板、发起人、是否公共、时间窗口、文件摘要(行数/大小/格式)。
## 8. 文件生成与格式
- `csv`
- 统一 `UTF-8``\n`;首行写列名;转义逗号与引号。
- 超大数据优先选择 `csv`,可并发分块写入后合并。
- `xlsx`
- 单 sheet 行数受限(建议 ≤ 100 万);超限自动分 sheet。
- 多 sheet 划分:按指定条件(如订单类型、日期分区)生成;需稳定排序避免跨批次混入。
- 列统计:
- 支持 `count/sum/avg/min/max`;可生成统计 sheet 或尾部汇总。
- 统计尽量在同一 SQL 通过窗口/聚合生成,避免二次扫描。
## 9. 前后端交互协议
- 模板管理 API
- `POST /api/templates` 创建模板并运行 `EXPLAIN` 校验。
- `GET /api/templates` 列表/搜索;`GET /api/templates/{id}` 详情。
- `POST /api/templates/{id}/validate` 重新校验与给出索引建议。
- `PATCH /api/templates/{id}` 更新元数据(公共/个人、描述、可见范围)。
- 导出执行 API
- `POST /api/exports` 发起导出(模板 + 权限范围 + 条件 + 格式 + 统计 + 多 sheet
- `GET /api/exports/{id}` 进度与指标;`GET /api/exports/{id}/download` 下载文件。
- `POST /api/exports/{id}/cancel` 取消任务。
- 前端约定:
- 使用 `Vue 3 + Element Plus`,通过 CDN 加载:`vue@3` 与 `element-plus`;页面在 `web/index.html` 中挂载。
- 统一通过权限范围参数(如 `user_id IN (...)`)传递查询边界;由后端注入到 SQL。
- 所有下拉/选择项来自白名单与元数据;禁止自由输入列名与表名;表单与日期选择使用 Element Plus 组件。
## 10. 性能与稳定性
- 索引策略:优先覆盖权限字段、关联键、排序键;定期审计慢查询。
- 分批与限流:根据 `EXPLAIN` rows 估算选择批大小;对导出并发做全局限流。
- 超时与重试:数据库查询、文件写入设置超时;幂等与断点续传支持。
- 资源管理:严格控制连接池、内存占用、磁盘 I/O避免阻塞主线程。
## 11. 测试与质量保障
- 单元测试SQL 构建器、权限注入、EXPLAIN 评估、CSV/XLSX 写入器。
- 集成测试:常见场景模板端到端导出与校验;超大数据模拟与性能门槛验证。
- 基准测试:关键路径(查询与写文件)压测,建立性能基线与预算。
## 12. 日志、监控与告警
- 指标:导出次数、耗时、行数、失败率、平均速率、并发数。
- 监控:数据库慢查询、队列堆积、磁盘空间、错误分布。
- 告警任务失败、耗时超阈值、EXPLAIN 超标、磁盘接近上限。
## 13. 版本控制与发布
- Git 工作流:特性分支 + 合并请求;语义化提交信息;模板变更需评审。
- 发布:后端可编译为单二进制;前端静态资源打包;提供启动脚本与环境样例。
- 变更管理:模板结构变更需迁移脚本与兼容策略;历史记录不丢失。
## 14. 角色与可见性
- 模板归属:支持公共与个人归属,公共模板需通过评审并标注责任人。
- 访问控制:按角色/租户限制模板使用与导出权限;下载链接带有效期与签名。
## 15. 错误处理与用户体验
- 明确错误权限缺失、EXPLAIN 未达标、索引建议、数据过大提示与替代方案。
- 进度与估算:持续更新预计完成时间与当前速率;完成后通知。
- 可恢复:失败任务可重试或继续;中断后支持断点续导出。
---
以上规则用于指导实现与评审,确保导出功能在安全、性能与可维护性方面达标。后续如有新场景与数据源接入,应当补充白名单与关联元数据,并通过测试与指标验证后上线。
## 16. 环境与连接配置
- 原则:所有连接信息通过环境变量注入,禁止将密码和密钥写入仓库或日志。
- 营销系统关系型数据库(示例变量名):
- `MARKETING_DB_HOST`
- `MARKETING_DB_PORT`
- `MARKETING_DB_USER`
- `MARKETING_DB_PASSWORD`
- `MARKETING_DB_NAME`
- `MARKETING_DB_MAX_OPEN_CONNS`、`MARKETING_DB_MAX_IDLE_CONNS`、`MARKETING_DB_CONN_MAX_LIFETIME`
- 易码通系统关系型数据库(示例变量名):
- `YMT_DB_HOST`
- `YMT_DB_PORT`
- `YMT_DB_USER`
- `YMT_DB_PASSWORD`
- `YMT_DB_NAME`
- 易码通系统 MySQL 驱动与 DSN
- 驱动:`mysql`(建议使用 `github.com/go-sql-driver/mysql`)。
- DSN 组合(推荐在运行时通过环境变量组装):
- 模板:`<user>:<password>@tcp(<host>:<port>)/<dbname>?parseTime=True&loc=Local&charset=utf8mb4`
- 对应变量:`YMT_DB_USER`、`YMT_DB_PASSWORD`、`YMT_DB_HOST`、`YMT_DB_PORT`、`YMT_DB_NAME`。
- 连接选项:
- `parseTime=True` 解析时间类型为 `time.Time`
- `loc=Local` 或设定为具体时区;如跨时区导出,优先统一到 `UTC` 并在前端展示时转换。
- `charset=utf8mb4` 以避免字符集问题。
- 生产建议:启用只读账号、限定白名单 IP、必要时开启 TLS禁止在仓库或日志中存储明文密码与完整 DSN。
- 营销系统 Redis缓存/队列等,示例变量名):
- `MARKETING_REDIS_HOST`
- `MARKETING_REDIS_PORT`
- `MARKETING_REDIS_PASSWORD`
- 使用规范:
- 本地与生产环境分别通过系统环境或安全的配置服务注入上述变量。
- 连接池、超时与重试策略必须可配置:查询/执行超时、读写超时、最大重试次数与退避策略。
- 优先开启 TLS/SSL如数据库与 Redis 支持),并使用白名单 IP 与最小权限账号。
- 在运行与监控面板中仅显示掩码信息(如 `***`),避免泄露。
## 17. 易码通数据库与表设计
- 存储位置:所有导出相关业务数据统一存储在易码通 MySQL`merketing`),使用 `InnoDB``utf8mb4`,时间统一为 `UTC`(前端展示时转换)。
- 表清单与职责:
- `export_templates`(导出模板主表):
- 字段:`id`、`name`、`datasource``ymt`/`marketing`)、`main_table`、`joins_json`、`fields_json`、`filters_json`、`file_format``csv`/`xlsx`)、`stats_enabled`、`sheet_split_by`、`visibility``public`/`private`)、`owner_id`、`enabled`、`explain_json`、`explain_score`、`last_validated_at`、`created_at`、`updated_at`。
- 索引:`owner_id`、`visibility`、`enabled`、`created_at`。
- `export_jobs`(导出任务表):
- 字段:`id`、`template_id`、`status``queued`/`running`/`failed`/`completed`/`canceled`)、`requested_by`、`permission_scope_json`(如 `user_ids`、`tenant_id` 范围)、`options_json`、`row_estimate`、`total_rows`、`file_format`、`started_at`、`finished_at`、`created_at`、`updated_at`。
- 索引:`template_id`、`status`、`requested_by`、`created_at`。
- `export_job_files`(导出文件记录):
- 字段:`id`、`job_id`、`storage_uri`、`sheet_name`、`row_count`、`size_bytes`、`checksum`、`created_at`。
- 索引:`job_id`。
- `export_audits`(审计与变更记录):
- 字段:`id`、`actor_id`、`action`、`entity_type`、`entity_id`、`detail_json`、`created_at`。
- 索引:`entity_type+entity_id`、`actor_id`、`created_at`。
- 可选:`export_metrics_daily`(按日聚合指标):`date`、`template_id`、`jobs_count`、`rows_count`、`avg_duration_ms` 等。
- 约束与规范:
- 外键:`export_jobs.template_id → export_templates.id``export_job_files.job_id → export_jobs.id`;模板被引用时 `ON DELETE RESTRICT`,避免误删。
- 主键:统一 `BIGINT UNSIGNED` 自增;布尔使用 `TINYINT(1)`JSON 字段用于结构化模板与权限范围。
- 命名:`snake_case`;时间字段包含 `created_at`、`updated_at`,应用层维护更新时间。
- 权限与多租户:
- 表层面包含 `owner_id``tenant_id` 以便分区过滤;任务执行范围记录于 `permission_scope_json`(如 `user_ids`、`tenant_id`、`date_range`)。
- 所有查询必须依赖权限字段建索引并注入过滤条件。
- 索引策略:
- 组合索引:`status, created_at`、`owner_id, created_at`、`template_id, created_at`,满足列表与检索高频路径。
- 针对主表与关联键建立覆盖索引,降低回表与避免 `Using filesort/temporary`
- 迁移与版本:
- 在 `server/migrations` 维护迁移脚本(`up/down`),每次结构变更必须提供兼容策略与数据迁移方案。
- 变更需通过测试与评审,记录在审计表中并与发布流程绑定。
- 数据保留与清理:
- 历史导出文件仅保留元数据与存储 URI按策略定期清理物理文件失败/取消任务保留审计但可清理文件记录。
- 性能与安全:
- 仅存储模板与任务元数据,不冗余导出结果数据;避免在表中存储敏感凭据;对大查询任务做限流与队列管理。
## 18. 营销系统订单主表定义
- 主表:`order`保留字SQL 中需使用反引号包裹:`` `order` ``)。
- 主键:`order_number``VARCHAR(20)`)。
- 权限字段:`creator``INT UNSIGNED`),用于用户权限过滤与范围查询,前端导出通过 `creator IN (...)` 传入范围参数。
- 字段要点:
- `` `key` ``(保留字,需使用反引号):业务 KEY。
- `out_trade_no`:支付流水号。
- `type`:订单类型,建议映射:`1=直充卡密`、`2=立减金`、`3=红包`(如与现网不一致,以现网为准)。
- `status`:订单状态;`deliver_status`:向上游投递状态。
- `account`、`product_id`、`reseller_id`、`plan_id`、`key_batch_id`、`code_batch_id`。
- `contract_price`、`num`、`total`(生成列:`contract_price * num`)、`pay_amount`、`pay_type`、`pay_status`、`use_coupon`。
- `expire_time`、`recharge_time`、`create_time`、`update_time`。
- `card_code`:敏感信息字段,默认不允许导出;需更高权限并进行掩码或脱敏处理。
- 索引:
- `PRIMARY KEY (order_number)`
- `idx_out_trade_no (out_trade_no)`、`idx_key (` `key` `)`、`idx_code_batch_id (code_batch_id)`、`idx_product_id (product_id)`、`idx_reseller_id (reseller_id)`、`idx_create_time_account (account, create_time)`。
- 复合索引:`idx_create_time_creator (create_time, creator)`、`idx_plan_id_create_time_creator (plan_id, create_time, creator)`。
- 导出查询规范:
- 所有营销系统订单导出必须包含 `creator` 范围过滤(例如:`creator IN (?)` 或等值),并优先结合 `create_time` 使用复合索引。
- 订单类型筛选通过 `type IN (1,2,3)`;支付相关通过 `pay_status`、`out_trade_no`;避免对未建索引列进行大范围过滤。
- 严禁 `SELECT *`;仅允许白名单列(默认排除 `card_code`)。
- 时间范围必填,建议按 `create_time` 进行分区或限窗,避免跨超大时间窗口的全量拉取。
- EXPLAIN 达标建议:
- 驱动表选择 `order`,过滤条件包含 `creator``create_time``type`、`plan_id`、`reseller_id` 作为附加过滤。
- 避免 `Using filesort/temporary` 在百万级数据上出现;如需排序,尽量使用已存在的复合索引顺序。
- 安全与合规:
- 对 `card_code` 输出采用掩码(如仅展示前 6 后 4权限不足禁止选取该列。
- 对支付流水号与账号字段进行最小必要输出;下载文件带签名与有效期。
- 命名注意:
- 表名 `order` 与列名 `` `key` ``为保留字,所有 SQL 需使用反引号包裹以避免语法冲突。
## 19. 营销系统订单详情表定义
- 表:`order_detail`(与主表 `order` 通过 `order_number` 一一关联)。
- 主键:`order_number``VARCHAR(20)`)。
- 关联关系:`order_detail.order_number = order.order_number`;查询时以 `order` 为驱动表进行 `INNER JOIN``LEFT JOIN`
- 字段要点:
- `plan_title`、`reseller_name`、`product_name`:冗余展示字段,来自计划/分销商/商品。
- `show_url`:商品图片 URL冗余
- `official_price`、`cost_price`:官方价与成本价;与 `order.contract_price` 有区分。
- `product`:冗余商品信息(`JSON`)。
- `refund_account`:退款打款账号(敏感,默认不导出或需掩码)。
- `create_time`、`update_time`:时间字段,跟随主表时区与展示规范。
- 索引:
- `PRIMARY KEY (order_number)`
- 建议增加:`idx_update_time (update_time)` 或 `idx_create_time (create_time)` 视查询需要;如按时间窗口筛选详情。
- 导出查询规范:
- 所有详情导出需通过与主表 `order` 关联,并在主表上应用权限过滤:`creator IN (...)` 与时间范围。
- 字段白名单:默认允许 `plan_title/reseller_name/product_name/show_url/official_price/cost_price/create_time/update_time``refund_account` 与 `product`JSON需更高权限或进行脱敏/选择性字段输出。
- 避免单独对 `order_detail` 进行大范围扫描;以主表为驱动确保使用主表的复合索引。
- EXPLAIN 达标建议:
- 连接以主表为驱动(主表过滤 `creator + create_time`),详情表通过主键或索引快速匹配,避免 `Using temporary` 与大范围 `filesort`
- 如需按详情时间排序,确保存在相应时间索引或采用分页与稳定排序键。
- 安全与合规:
- 对 `refund_account` 默认隐藏或掩码(如仅展示部分);`product` JSON 仅输出必要键,避免泄露多余属性。
- 下载与展示遵循最小必要原则,确保敏感字段在模板白名单中默认关闭。
## 20. 营销系统红包订单副表定义
- 表:`order_cash`(与主表 `order` 通过 `order_number` 一一关联)。
- 主键:`order_number``VARCHAR(20)`)。
- 关联关系:`order_cash.order_number = order.order_number`;查询以主表为驱动表进行 `INNER JOIN``LEFT JOIN`
- 字段要点:
- `channel`:渠道(`1=支付宝`、`2=微信`)。
- `cash_activity_id`:红包批次号;`cash_packet_id`:红包 ID`cash_id`:红包模板/规则 ID。
- `receive_status`:领取状态;`receive_time`:拆红包时间。
- `channel_order_id`:支付宝转账订单号/微信批次单号;`pay_fund_order_id`:支付宝资金流水;`wechat_detail_id`:微信商家明细单号。
- `receive_user_id`:领取者唯一标识(微信 `open_id`/支付宝 `alipay_user_id`),属于敏感标识,默认不导出或需脱敏。
- `amount`:红包额度;`status`:状态(`1正常`、`2过期``expire_time`:过期时间。
- `receive_name`领取方真实姓名PII默认不导出或掩码
- `update_time`:更新时间(可用于按更新时间筛选)。
- 索引:
- `PRIMARY KEY (order_number)``UNIQUE o_uidx (order_number)`(二者功能重合,建议保留一个唯一约束以避免重复约束)。
- `r_time_idx (receive_time)`、`r_c_idx (receive_user_id, cash_id)`、`idx_expire_time_status (expire_time, status)`。
- 导出查询规范:
- 红包订单导出需与主表 `order` 关联,并在主表上应用权限过滤:`creator IN (...)` 和时间范围。
- 字段白名单:默认允许 `channel/cash_activity_id/receive_status/receive_time/cash_packet_id/cash_id/amount/status/expire_time/update_time``receive_user_id/receive_name/channel_order_id/pay_fund_order_id/wechat_detail_id` 需更高权限或掩码处理。
- 避免对未索引字段进行大范围过滤;按 `receive_time``expire_time,status` 进行筛选时使用相应索引。
- EXPLAIN 达标建议:
- 以主表为驱动(主表过滤 `creator + create_time`),副表通过主键或索引匹配;避免在大数据集上出现 `Using temporary``filesort`
- 排序尽量使用已存在索引的前缀;对 `receive_time` 排序应结合筛选与分页。
- 安全与合规:
- `receive_user_id``receive_name` 属 PII模板默认关闭并需权限校验与脱敏如散列或局部显示
- 渠道订单/资金流水号仅在合规范围内输出,避免泄露与被用作外部关联键。
## 21. 营销系统立减金订单表定义
- 表:`order_voucher`(与主表 `order` 通过 `order_number` 关联;亦可通过唯一 `trade_no` 与外部交易做关联)。
- 主键:`id`(自增,`INT`)。唯一键:`trade_no`。
- 关联关系:`order_voucher.order_number = order.order_number`;查询以主表为驱动表进行 `INNER JOIN``LEFT JOIN`
- 字段要点:
- 渠道与批次:`channel``1=支付宝`、`2=微信`)、`channel_activity_id`(渠道立减金批次)、`channel_voucher_id`(渠道立减金 ID
- 用户标识:`channel_user_id`(渠道用户 IDPII默认不导出或需脱敏
- 规则与状态:`rule`(立减金规则,`JSON`)、`status``1可用/2已实扣/3已过期/4已退款`)。
- 时间:`grant_time`(领取)、`usage_time`(核销)、`refund_time`(退款)、`status_modify_time`(状态更新时间)、`overdue_time`(过期)。
- 价格与账户:`official_price`、`refund_amount`、`receive_mode``1渠道授权用户id/2手机号或邮箱`)、`receive_error`、`app_id`、`out_biz_no`、`fail_time`、`notify_url`、`account_no`。
- 索引:
- `PRIMARY KEY (id)`、`UNIQUE idx_trade_no (trade_no)`。
- `idx_order_number (order_number)`、`idx_channel_uid (channel, channel_user_id)`、`idx_channel_user_id (channel_user_id, channel)`、`idx_channel_voucher_id (channel_voucher_id)`、`idx_activity_voucher (channel_activity_id, channel)`、`index_status (status)`、`idx_usage_time (usage_time)`、`idx_out_biz_no (out_biz_no)`。
- 导出查询规范:
- 立减金订单导出需与主表 `order` 关联,并在主表上应用权限过滤:`creator IN (...)` 与时间范围;避免对本表进行独立全表扫描。
- 字段白名单:默认允许 `channel/channel_activity_id/channel_voucher_id/status/grant_time/usage_time/refund_time/status_modify_time/overdue_time/refund_amount/official_price/out_biz_no/account_no``channel_user_id/app_id/notify_url/receive_error` 与 `rule`JSON需更高权限或脱敏后选择性输出。
- 规则字段 `rule` 建议由模板定义具体键路径进行选择性导出,避免整段 JSON 输出造成泄露与体积膨胀。
- EXPLAIN 达标建议:
- 以主表为驱动(主表过滤 `creator + create_time`),本表通过 `order_number` 或其他索引列匹配;对时间或状态筛选使用对应索引。
- 如需要按 `usage_time``status` 排序与筛选,确保选择性足够并配合分页,避免 `Using filesort/temporary` 在大数据集上出现。
- 安全与合规:
- `channel_user_id` 属 PII模板默认关闭并需脱敏如散列或局部显示`account_no` 亦需最小必要输出与掩码。
- `notify_url/out_biz_no/app_id` 等用于渠道交互的字段不用于外部分发,谨慎输出并记录审计。
## 22. 营销系统活动计划表定义
- 表:`plan`(营销计划与活动配置)。
- 主键与唯一:`id`(自增,`INT UNSIGNED`);唯一键 `stock_id`(计划编号/批次号)。
- 关联关系:`order.plan_id = plan.id`;订单侧查询通过主表驱动与计划表关联。
- 权限字段:`creator`(数据所属用户);可补充按 `reseller_id` 进行租户/渠道范围过滤。
- 字段要点:
- 创建者:`creator`、`creator_name`。
- 基本信息:`title`(计划标题)、`type`(计划类型)、`status`(活动状态,`0草稿` 等)。
- 分销商:`reseller_id`、`reseller_name`(冗余)。
- 时间:`begin_time`、`end_time`(活动窗口)、`create_time`、`update_time`、`delete_time`(软删除)。
- 计划与结算:`return_type`、`open`1开启/2关闭、`settlement_type`、`send_method`1邮件/2API
- 压缩包与安全:`zip_file`、`zip_file_md5`、`zip_pwd`(敏感,默认不导出或需脱敏/掩码)。
- 其他:`approval_id`、`copy_count`、`stock_id`、`button_conf``JSON`)。
- 索引:
- `udx_stock_id (stock_id)` 唯一;`idx_reseller (begin_time, end_time, reseller_id)``idx_status_creator (status, creator)``idx_end_time (end_time)`。
- 建议:如存在常用 `begin_time` 范围筛选与按状态查询,可添加 `status, begin_time` 复合索引以提高选择性。
- 导出查询规范:
- 计划相关导出需结合主表订单或自身权限字段进行过滤:`creator IN (...)` 或 `reseller_id IN (...)` 与时间窗口 `begin_time ≤ t ≤ end_time`
- 默认白名单不包含 `zip_pwd/zip_file_md5` 等敏感字段;如需导出,必须具备更高权限并进行掩码或仅输出校验摘要。
- 对软删除记录:导出与展示需排除 `delete_time IS NOT NULL` 条目(或由模板显式选择策略)。
- EXPLAIN 达标建议:
- 针对计划列表与关联查询,优先使用 `status, creator``begin_time, end_time, reseller_id` 组合索引;时间窗口筛选需与索引前缀一致以避免全表扫描。
- 计划与订单关联时,以订单为驱动(带 `creator + create_time` 的过滤),计划表通过主键或索引快速匹配,避免 `Using temporary/filesort`
- 安全与合规:
- `zip_pwd` 等敏感信息严格限制输出并进行审计;`button_conf`JSON默认关闭或选择性键导出。
- 下载与展示遵循最小必要原则,模板白名单默认不包含敏感字段;所有导出操作记录审计。
## 23. 营销系统 KEY 批次表定义
- 表:`key_batch`KEY 批次与库存配置)。
- 主键:`id`(自增,`INT UNSIGNED`)。
- 关联关系:`key_batch.plan_id = plan.id`;订单侧冗余引用为 `order.key_batch_id`
- 权限字段与范围:
- `creator`(创建人用户 ID用于权限过滤与范围查询与计划表 `creator` 协同过滤。
- 结合 `plan_id` 与活动时间窗口进行边界限定(`begin_time ≤ t ≤ end_time`)。
- 字段要点:
- 基本信息:`style`KEY 样式)、`batch_name`(批次名称)。
- 绑定与数量:`bind_object`1兑换码/2优惠码/4立减金、`quantity`(发放数量)、`stock`(剩余库存)、`restrict`(最大绑定数量)。
- 行为配置:`allow_repetition`(是否允许重复选商品)、`merge_stock`(是否合并库存)、`allow_loss`(是否允许亏损)。
- 时间与状态:`begin_time`、`end_time`、`status`、`create_time`、`update_time`、`delete_time`(软删除)、`discard_time`(作废时间)。
- 冗余与敏感:`code_batch`(关联兑换码批次 JSON、`zip_file/zip_file_md5/zip_pwd`(敏感,默认不导出或需脱敏/掩码)。
- 成本与价格:`key_official_price`、`key_cost_price`;有效期与预警:`expiration_conf`、`warning_conf`JSON
- 其他:`generate_id`(生成记录 ID、`creator_name`、`approval_status`、`approval_id`、`mobile_excel`(白名单 Excel 路径)、`mobile_repeat`(白名单允许重复号码 JSON、`copy_count`。
- 索引:
- 现有:`idx_end_time (end_time)`、`idx_plan_id (plan_id)`。
- 建议:如常按 `status + end_time``begin_time + end_time` 过滤,增加相应复合索引;对 `creator + create_time` 的审计或列表查询可新增复合索引以提高选择性。
- 导出查询规范:
- 所有与 KEY 批次相关的导出需结合权限:`creator IN (...)` 或通过计划与订单的权限边界进行过滤;必须包含时间窗口限定。
- 字段白名单:默认允许 `plan_id/style/batch_name/bind_object/quantity/stock/allow_repetition/merge_stock/allow_loss/begin_time/end_time/status/create_time/update_time/key_official_price/key_cost_price`;敏感字段 `zip_file/zip_file_md5/zip_pwd/mobile_excel/mobile_repeat/code_batch/expiration_conf/warning_conf` 默认关闭或仅选择性键导出。
- 禁止对未建索引字段进行大范围过滤;尽量以计划或订单为驱动进行关联查询,减少在批次表上的独立全表扫描。
- EXPLAIN 达标建议:
- 过滤条件包含 `plan_id` 与时间窗口;如按 `status``creator` 列表查询,需配合复合索引并分页,避免 `Using filesort/temporary`
- 与订单或计划表关联时,以高选择性过滤的表作驱动(通常订单或计划),批次表通过主键/索引快速匹配。
- 安全与合规:
- 任何涉及白名单文件路径、压缩包与密码的字段默认不导出;需要更高权限且输出内容需进行掩码或摘要化(如仅输出 MD5 校验)。
- JSON 字段导出走模板键路径选择,避免整段输出造成泄露与体积膨胀;所有导出操作记录审计。
## 24. 营销系统兑换码批次表定义
- 表:`code_batch`(兑换码/优惠券批次信息)。
- 主键:`id`(自增,`INT UNSIGNED`)。
- 关联关系:
- `code_batch.key_batch_id = key_batch.id`(外键逻辑关联)。
- 订单侧冗余引用为 `order.code_batch_id`,查询时以订单或 KEY 批次为驱动进行关联。
- 权限字段:`creator`(创建者用户 ID用于导出权限过滤与范围控制。
- 字段要点:
- 基本:`status`、`title`、`plan_title`(冗余)、`describe`(兑换说明)、`range`(使用范围描述)。
- 时间:`begin_time`、`end_time`、`create_time`、`update_time`、`delete_time`、`discard_time`。
- 数量与库存:`quantity`、`usage`、`invalid`、`stock`(生成列:`(quantity - usage) - invalid`)。
- 限额与类型:`restrict`(绑定限额次数)、`type``0兑换码/1优惠券`)、`recharge_type``1单个充值/2组合充值`)。
- 价格与组合:`full`(满额)、`reduce`(减额)、`group_info`(组合商品基础信息,`JSON`)。
- 审批与复制:`approval_status`、`approval_id`、`copy_count`。
- 产品冗余:`product``JSON`)。
- 周期配置:`period_type``1不设置/2自动/3手动`)、`period_num`、`period_day`、`period_fixed_receive_time``time`)。
- 索引:
- 现有:`key_batch_id (key_batch_id)`、`idx_end_time (end_time)`。
- 建议:常用筛选可增加 `creator, create_time``status, end_time` 复合索引以提升选择性;按标题/计划模糊查询建议走前端限定与分页,避免无索引扫描。
- 导出查询规范:
- 导出需在主表(订单或 KEY 批次)应用权限与时间过滤:`creator IN (...)`、`begin_time ≤ t ≤ end_time`,再关联到 `code_batch`
- 字段白名单:默认允许 `title/status/begin_time/end_time/quantity/usage/invalid/stock/restrict/type/recharge_type/full/reduce/create_time/update_time``product/group_info/range`(大字段/JSON默认关闭需更高权限且建议选择性键导出。
- 大范围筛选避免对未索引列进行;按 `end_time``status` 过滤时利用对应索引并配合分页。
- EXPLAIN 达标建议:
- 以订单或 KEY 批次为驱动(带 `creator + 时间窗口` 过滤),`code_batch` 通过主键或索引列快速匹配;避免 `Using temporary/filesort` 在大数据集出现。
- 对组合/周期场景,必要时拆分查询或利用窗口函数在可支持的引擎上完成统计,减少回表与二次扫描。
- 安全与合规:
- `product`、`group_info`、`range` 等可能包含敏感或大体积信息,模板默认不导出;若需要输出,采用键路径与掩码策略并记录审计。
- 软删除记录(`delete_time IS NOT NULL`)默认不参与导出,除非模板明确要求。
## 25. 营销系统 key 码发放记录表定义
- 表:`merchant_key_send`(开放平台 key 码发放记录)。
- 主键与唯一:
- `PRIMARY KEY (id)``BIGINT UNSIGNED` 自增)。
- `UNIQUE udx_key (key)`(单码唯一)。
- `UNIQUE udx_merchant_id_out_biz_no (merchant_id, out_biz_no)`(商户侧业务号唯一)。
- 关联关系:
- `key_batch_id → key_batch.id``plan_id → plan.id``stock_id` 与 `plan.stock_id` 逻辑关联。
- 可与订单主表通过 `` `order`.`key` = merchant_key_send.key `` 进行关联以查询核销状态与订单信息。
- 权限与范围:
- 按 `reseller_id`(分销商)或 `merchant_id`(开放商户)进行范围过滤;前端导出通过传递 `reseller_id IN (...)``merchant_id IN (...)`
- 必须包含时间窗口(如 `create_time``status_update_time`)与状态筛选以提高选择性。
- 字段要点:
- 基本:`reseller_id`、`merchant_id`、`out_biz_no`、`out_timestamp`、`key_batch_id`、`stock_id`、`plan_id`、`store_id`。
- 券码:`key`、`status``1待发放/2已核销/3已作废/4充值中`)、`num`(发放数量)。
- 客户端与账号:`attach`(客户端请求参数,`JSON`)、`account_type`、`account`、`send_msg`(短信是否发送)。
- 时间:`usage_time`(核销时间)、`discard_time`(作废时间)、`status_update_time`(状态变更时间)、`update_time`、`create_time`。
- 索引:
- 现有:`udx_key (key)`、`udx_merchant_id_out_biz_no (merchant_id, out_biz_no)`、`idx_merchant_id_stock_id (merchant_id, stock_id)`、`idx_stock_id (stock_id)`、`idx_out_biz_no (out_biz_no)`、`idx_create_time (create_time)`。
- 建议:如常按状态或时间查询,增加 `status, status_update_time` 复合索引;针对分销商或商户维度增加 `reseller_id, create_time``merchant_id, create_time` 复合索引。
- 导出查询规范:
- 作为主导出场景时,必须包含权限范围(`reseller_id/merchant_id`)与时间窗口(`create_time/status_update_time`);必要条件如 `status IN (...)`
- 与订单或批次关联导出时,以高选择性表为驱动(如订单带 `creator + create_time` 过滤 或 批次/计划带时间与状态过滤)。
- 字段白名单:默认允许 `reseller_id/merchant_id/out_biz_no/out_timestamp/key_batch_id/stock_id/plan_id/store_id/key/status/num/usage_time/discard_time/status_update_time/update_time/create_time``attach/account` 等敏感或 PII 默认关闭或掩码输出(如部分显示或哈希)。
- EXPLAIN 达标建议:
- 过滤优先使用 `merchant_id/reseller_id + create_time``status + status_update_time` 组合索引;对 `key` 的点查走唯一索引。
- 排序尽量与索引前缀一致并配合分页,避免在大数据集上 `Using filesort/temporary`
- 安全与合规:
- `account` 属 PII模板默认不导出如需导出必须具备更高权限与掩码策略。
- `attach` JSON 仅导出必要键;严禁导出包含凭据或签名的字段;所有导出操作记录审计。
## 26. 营销系统立减金表定义
- 表:`voucher`(立减金批次与预算配置)。
- 主键与唯一:`id`(自增,`INT UNSIGNED`);唯一键 `channel_activity_id`(渠道立减金批次号)。
- 关联关系:
- 与立减金订单表 `order_voucher.channel_activity_id = voucher.channel_activity_id` 进行关联;
- 可与商品 `goods_id` 关联到商品表(如存在),与订单的 `product_id` 做间接关联。
- 字段要点:
- 渠道与批次:`channel``1支付宝/2微信`)、`channel_activity_id`、`batch_goods_name`。
- 金额与额度:`price`(合同单价)、`recharge_amount`(充值批次金额)、`frozen_amount`(冻结额度)、`balance`(剩余额度)、`used_amount`(生成列:`recharge_amount - balance`)、`denomination`(面额)、`reduce_amount`(立减额)。
- 预算:`all_budget`(总预算)、`day_budget`(单天预算)。
- 配置:`receive_conf`(领取配置,`JSON`)、`card_type`(卡种类型,`JSON`)、`time_limit`(时间限制配置,`JSON`)。
- 说明与通知:`instruction`(使用说明,`TEXT`)、`early_per`(预警百分比,`JSON`)、`early_notifier`(预警通知人,`JSON`)、`last_early_per`(上次预警百分比)。
- 模板与渠道:`temp_no`(券模板编号,仅支付宝)、`provider`(微信服务商标识,可选)。
- 发券方式与数量:`receive_mode``1渠道用户id/2手机号或邮箱`)、`send_num`(发放数量)、`is_webview`(是否 webview 方式)。
- 其他:`notice`(使用须知)、`index`(排序)、`goods_id`(商品)。
- 时间:`create_time`、`delete_time`(软删除)。
- 索引:
- 现有:唯一 `channel_activity_id``idx_goods_id (goods_id)`。
- 建议:如常按渠道与批次过滤,增加 `channel, channel_activity_id` 复合索引(若不会与唯一冲突的查询场景);按创建时间列表查询可添加 `create_time` 索引;若按状态类筛选(来自配置),考虑在业务层限定,避免无选择性扫描。
- 导出查询规范:
- 立减金导出应通过与 `order_voucher` 或订单主表关联来继承权限边界(如 `creator IN (...)` 或租户范围),避免对 `voucher` 进行独立全表导出。
- 字段白名单:默认允许 `channel/channel_activity_id/batch_goods_name/price/recharge_amount/frozen_amount/balance/used_amount/denomination/reduce_amount/all_budget/day_budget/provider/receive_mode/send_num/is_webview/create_time`;大字段/JSON`receive_conf/card_type/time_limit/early_per/early_notifier/instruction/notice`)默认关闭或选择性键导出。
- 如需按商品维度导出,需通过授权的 `goods_id IN (...)` 列表或与订单/商品白名单关联实现权限控制。
- EXPLAIN 达标建议:
- 关联查询以 `order_voucher` 或订单为驱动(带 `creator + 时间窗口` 过滤),`voucher` 通过唯一或索引列快速匹配;
- 列表查询按 `create_time``channel_activity_id` 做点查/前缀匹配并分页,避免 `Using filesort/temporary`
- 安全与合规:
- JSON 与说明类大字段默认不导出;若需输出,采用键路径与最小必要原则,并记录审计。
- 避免导出可能包含渠道敏感配置或内部标识(如 `temp_no/provider`)到公共模板;需权限校验与用途限定。
## 27. 营销系统立减金批次表定义
- 表:`voucher_batch`(立减金批次与渠道映射)。
- 主键与唯一:`id`(自增,`INT UNSIGNED`);唯一键 `udx_channel_activity_id (channel_activity_id)`
- 关联关系:
- `voucher_batch.voucher_id = voucher.id`(立减金主表关联)。
- 与立减金订单 `order_voucher.channel_activity_id = voucher_batch.channel_activity_id` 间接关联。
- 字段要点:
- 渠道批次与模板:`channel_activity_id`(渠道立减金批次号)、`temp_no`(券模板编号,仅支付宝)。
- 服务商:`provider`(微信/支付宝可选)。
- 权重:`weight`(用于批次选择或优先级控制)。
- 时间:`create_time`、`update_time`。
- 索引:
- 现有:唯一 `channel_activity_id`
- 建议:如按 `voucher_id` 维度频繁查询,增加 `voucher_id` 索引;列表按时间查询可增加 `create_time` 索引。
- 导出查询规范:
- 批次信息通常作为维表使用,导出应通过与 `voucher``order_voucher` 关联来继承权限边界(如订单的 `creator IN (...)`)。
- 字段白名单:默认允许 `voucher_id/channel_activity_id/provider/temp_no/weight/create_time/update_time`;避免导出仅内部使用的渠道敏感标识到公共模板。
- EXPLAIN 达标建议:
- 关联查询通过唯一或索引列(`channel_activity_id`/`voucher_id`)点查;列表查询按时间索引并分页,避免 `Using filesort/temporary`
- 安全与合规:
- 渠道模板编号与服务商标识可能为内部标识;默认仅在具备相应权限的模板中使用,导出需审计。
## 28. 营销系统表关系与映射
- 驱动表与权限:
- 驱动表:`order`(主键 `order_number`,权限字段 `creator`)。
- 所有导出查询以 `order` 为驱动,强制注入权限过滤:`creator IN (...)`,并结合时间范围 `create_time`
- 关系与连接(均采用 `LEFT JOIN`,仅当业务语义要求强关联时可用 `INNER JOIN`
- 详情:`order_detail.order_number = order.order_number`。
- 红包:`order_cash.order_number = order.order_number`。
- 立减金订单:`order_voucher.order_number = order.order_number`。
- 立减金:`voucher.channel_activity_id = order_voucher.channel_activity_id`。
- 立减金批次:`voucher_batch.voucher_id = voucher.id`。
- 计划:`plan.id = order.plan_id`。
- KEY 批次:`key_batch.plan_id = plan.id`。
- 兑换码批次:`code_batch.key_batch_id = key_batch.id`。
- 开放平台发放记录:`` `order`.`key` = merchant_key_send.key ``。
- 连接键索引与保留字:
- 为上述连接键建立或验证必要索引:`order.order_number`、`order.creator, order.create_time`、`order_detail.order_number`、`order_cash.order_number`、`order_voucher.order_number`、`voucher.channel_activity_id`、`voucher_batch.voucher_id`、`plan.id`、`key_batch.plan_id`、`code_batch.key_batch_id`、`merchant_key_send.key`。
- 表名 `order` 与列名 `` `key` ``为保留字SQL 中需使用反引号包裹以避免语法冲突。
- 模板生成 SQL 的连接顺序建议:
- 优先应用驱动表过滤(`creator + create_time + type/...`),再按选择性从高到低依次连接:计划 → KEY 批次 → 兑换码批次;立减金订单 → 立减金 → 立减金批次;红包;详情;发放记录。
- 避免无条件连接造成笛卡尔积;每个连接必须具备明确的等值连接键。
- 性能与 EXPLAIN 要点:
- 连接计划应使高选择性条件尽早生效;排序字段尽量与索引前缀一致。
- 对超大数据场景采用分页与稳定排序键;避免在大范围上出现 `Using temporary``Using filesort`
- 字段白名单与敏感字段:
- 默认排除敏感列(如 `card_code`、账号与渠道标识、JSON 大字段);如需导出需更高权限并进行掩码或选择性键导出。
- 前端模板仅提供白名单字段选择,禁止自由输入列名与表名。
## 29. 操作面板与模板生成流程
- 面板步骤:
- 选择数据源:`营销系统` 或 `易码通`(当前默认支持“订单数据”场景)。
- 选择导出场景:`订单数据`(后续可扩展其他场景)。
- 选择附表信息依据“表关系与映射”显示可选附表与连接键用户勾选所需的详情、红包、立减金、计划、KEY 批次、兑换码批次、发放记录等。
- 设置权限范围与条件:必填 `creator IN (...)`(或租户范围)与时间窗口(如 `order.create_time`);可选类型、状态、渠道、计划等条件;所有条件走参数化。
- 选择字段与输出格式:仅白名单字段;输出格式 `csv/xlsx`;是否列统计、是否按条件分多 sheet。
- 确认生成模板:生成模板并运行 `EXPLAIN` 评估,达标后方可保存与执行。
- SQL 生成:
- 以驱动表 `order` 构建 `SELECT`;按已选附表生成 `LEFT JOIN`,连接条件来自“表关系与映射”。
- 强制注入权限与时间条件;仅允许白名单列与等值/范围过滤;禁止自由拼接 SQL 与 `SELECT *`
- 排序与分页:按索引前缀字段排序;大数据导出采用分页与稳定排序键;尽量一次查询覆盖需求并流式写文件。
- 多 sheet可按指定列如订单类型或日期拆分保持稳定排序避免跨批次混入。
- 列统计:在同一 SQL 使用聚合/窗口生成统计或追加统计 sheet避免二次扫描。
- EXPLAIN 分析面板:
- 展示核心字段:`id`、`select_type`、`table`、`type`、`possible_keys`、`key`、`key_len`、`ref`、`rows`、`filtered`、`Extra`。
- 评估规则:禁止大表 `type=ALL`;优先 `ref/range/const``rows` 合理可控;避免超大数据集上的 `Using temporary/filesort`
- 达标门槛:满足上述规则后允许执行;超标给出索引建议与模板调整指引(减少字段、改时间窗口、补充过滤或新增索引建议)。
- 模板保存与可见性:
- 保存为 `export_templates` 记录,包含数据源、主表、关联、字段、过滤、格式、统计、多 sheet、可见性公共/个人)、所有者、`EXPLAIN` 结果与评分。
- 公共模板需通过评审并标注责任人;个人模板仅本人可见或按角色授权可见。
- 执行与进度:
- 发起导出后记录到 `export_jobs`;展示总行数估算/实际、当前批次、速率、预计完成时间与状态;支持取消与失败重试。
- 文件记录存入 `export_job_files`,下载链接带签名与有效期;支持 `csv/xlsx`
- 安全与合规:
- 敏感字段默认不可选(如 `card_code`、账号、渠道标识与大 JSON需要更高权限并进行掩码或选择性键导出。
- 所有参数化与权限注入由后端统一实现;导出与下载行为记录审计。
## 30. 字段选择与列名约定
- 默认选择:
- 默认仅导出主表 `order` 的白名单字段;附表字段需用户显式勾选。
- 默认排除敏感字段(如 `card_code`、账号标识、渠道标识、大 JSON 等)。
- 列名来源:
- 优先使用数据库字段的 `COMMENT` 作为导出列名(中文优先)。
- 如 `COMMENT` 为空或过于技术化,则:
- 使用模板中的别名作为列名;
- 或将列名从 `snake_case`/`camelCase` 转为可读名(如 `order_number``订单编号`,无词典时保留原名)。
- 冲突与去重:
- 当不同表存在同名字段并被选择时,自动加前缀(如 `订单.订单编号`、`计划.计划编号`)或根据模板别名消除歧义。
- 同一语义字段重复选择时进行去重或保留一个主列,遵循模板优先级。
- 排序与输出:
- 列顺序遵循用户选择顺序;主表字段在前,附表字段在后;统计列或多 sheet 信息追加在末尾或独立 sheet。
- 文本统一 `UTF-8`;数值与时间按统一格式输出(时间默认 `YYYY-MM-DD HH:mm:ss`,时区统一为 `UTC` 或模板指定)。
- 安全与合规:
- 对被允许导出的敏感列进行掩码如账号、姓名仅展示部分JSON 字段仅允许按键路径选择性导出。
- 列名中不包含敏感信息或内部标识;对渠道或凭据相关内容默认不展示于列名与文件中。
## 31. 新增导出模板整体流程
- 新增导出:在操作面板点击“新增导出”。
- 选择数据源:`营销系统` 或 `易码通`
- 选择导出场景:当前支持 `订单数据`(后续可扩展)。
- 自定义导出字段:根据“表关系与映射”逐步选择主表与附表字段;默认仅主表字段,附表需显式勾选;仅白名单字段。
- 生成模板:点击“生成模板”,后端构建 SQL 并执行 `EXPLAIN` 分析。
- 返回分析结果:展示 `EXPLAIN` 关键项与评估结论;若达标则保存模板并可执行导出;未达标返回索引建议与模板调整提示。
- 执行导出:可立即基于模板发起导出,进入进度追踪与文件下载流程。
## 32. 模板列表与检索
- 列表范围:展示“内置公共模板”与“当前账号所有模板”(个人/共享)。管理员或具备相应角色可查看全部模板。
- 基本展示列:
- 模板名称、数据源(营销系统/易码通)、场景(订单数据)、主表、可见性(公共/个人)、所有者、创建/更新时间。
- EXPLAIN 评分与最近校验时间、字段数量、关联表数量、输出格式csv/xlsx、统计开启与多 sheet 条件。
- 过滤条件:
- 数据源、场景、可见性、所有者、创建时间范围、启用状态、EXPLAIN 评分区间、标签(如渠道/计划/类型)。
- 关键字搜索支持名称与主表/关联表名,采用前缀匹配与分页限制。
- 排序与分页:
- 支持按更新时间、创建时间、EXPLAIN 评分、预计行数等排序;统一服务端分页与总数统计。
- 快捷操作:
- 预览 SQL敏感信息掩码、重新校验 EXPLAIN、编辑元数据、启用/停用、发起导出、查看历史下载、复制fork模板、删除仅未被引用或无权限边界影响时
- 权限与可见性:
- 公共模板所有符合角色/租户的用户可见与使用;个人模板仅所有者或授权角色可见;维护者可在可见范围内编辑公共模板。
- 删除受限:被导出记录引用的模板不可直接删除(或需先停用并解除引用)。
- 安全与审计:
- 列表与详情不展示完整 DSN 或任何敏感凭据SQL 预览中掩码权限范围与敏感列。
- 模板的启停、删除、复制、校验与发起导出操作均记录审计日志。
## 33. 导出记录查看与操作
- 展示内容:
- 文件大小(`size_bytes` 聚合显示与各文件明细)。
- 文件地址(`storage_uri`,展示可下载的签名链接)。
- 数据量大小(总行数与各文件 `row_count`)。
- 导出开始时间(`started_at`)与完成时间(`finished_at`)。
- 执行人 ID`requested_by`)。
- 任务状态(`queued/running/failed/completed/canceled`)与耗时。
- 列表来源:
- 来自 `export_jobs``export_job_files` 的聚合视图;多文件(多 sheet 或分块)展示合计与明细展开。
- 过滤与排序:
- 按执行人、时间范围、状态、模板、数据源过滤;按完成时间、耗时、文件大小、行数排序。
- 服务端分页与总数统计;支持关键字搜索模板名称与执行人。
- 行操作:
- 下载提供带签名与有效期的下载链接支持单文件与批量ZIP 封装)。
- 删除:
- 仅拥有者或具备相应角色可删除;公共任务需具备维护权限。
- 默认删除逻辑:删除文件的物理存储并保留 `export_jobs` 元数据与审计;或将记录标记为已清理。
- 对正在运行的任务不可删除;对失败任务可清理文件记录与中间产物。
- 权限与合规:
- 下载链接签名与有效期控制,防止外泄;公开范围需受角色与租户限制。
- 列表中不展示敏感参数;文件名与路径不包含凭据或内部标识。
- 审计与保留:
- 下载与删除操作记录审计(操作者、时间、目标文件、结果)。
- 数据保留策略:按时间与空间配额定期清理历史文件,仅保留元数据;可配置每模板或每执行人的保留上限。
## 34. 模板行执行导出与进度展示
- 触发入口:
- 模板列表的每一行提供“执行导出”按钮;点击后创建一条新的导出任务记录并异步执行。
- 任务创建:
- 在 `export_jobs` 新增记录:`template_id`、`requested_by`、`permission_scope_json`、`options_json`、`file_format`、`status=queued`、`created_at`。
- 初始化 `row_estimate`(来自 `EXPLAIN` 或估算流程)、`started_at`(排队开始)、`total_rows`(执行中累计)。
- 异步执行:
- 任务状态流转:`queued → running → (completed | failed | canceled)`;支持取消与失败后重试。
- 并发与限流:对全局与每账号的并发进行限流与队列调度,避免资源争抢。
- 预计完成时间ETA
- 依据 `row_estimate` 与实时写入速率(行/秒)计算 ETA并随进度动态更新若估算缺失先以启动阶段速率预估后修正。
- 进度展示:
- 实时显示已写行数、当前批次、平均速率、ETA、状态与错误摘要。
- 推送方式SSE/WebSocket 或按 1-3 秒轮询;界面在模板行与导出记录页面均显示进度。
- 完成反馈:
- 导出完成后弹出通知,提供下载入口;同时在“导出记录”页可下载。
- 文件记录:在 `export_job_files` 新增记录并展示 `storage_uri`(签名链接)、`row_count`、`size_bytes`、`sheet_name`(如多 sheet
- 安全与合规:
- 下载链接签名与有效期;权限校验基于模板的可见范围与执行人身份。
- 进度与日志不展示敏感参数与凭据;错误信息进行安全脱敏。

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM golang:1.21-alpine AS build
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apk apk add build-base
COPY server/ ./server/
COPY web/ ./web/
WORKDIR /app/server
RUN go build -o /app/bin/marketing-data-server ./cmd/server
FROM alpine:3.19
WORKDIR /app
COPY --from=build /app/bin/marketing-data-server /app/bin/marketing-data-server
COPY --from=build /app/web /app/web
RUN mkdir -p /app/storage/export
EXPOSE 8077
ENTRYPOINT ["/app/bin/marketing-data-server"]

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# MarketingSystemDataTool
营销系统、易码通数据工具
## 服务启动方式
### 后端服务
1. **编译服务**(如未编译):
```bash
cd server
go build -o server ./cmd/server/main.go
```
2. **启动服务**
```bash
cd server
./server
```
3. **服务配置**
- 配置文件:`server/config.yaml`
- 默认端口8077
- 可通过环境变量覆盖配置
### 前端访问
服务启动后,通过浏览器访问:
```
http://localhost:8077
```
## 技术栈
- 后端Go 1.21
- 前端Vue 3 + Element Plus通过 CDN 引入)
- 数据库MySQL
- 导出格式CSV、Excel
## 项目结构
- `server/`Go 服务端代码
- `web/`:前端页面与静态资源
- `config/`:非敏感配置
- `scripts/`:开发与运维脚本

15
config/whitelist.json Normal file
View File

@ -0,0 +1,15 @@
{
"tables": ["order"],
"fields": [
"order.order_number","order.creator","order.out_trade_no","order.type","order.status","order.contract_price","order.num","order.total","order.pay_amount","order.create_time","order.update_time",
"order_detail.plan_title","order_detail.reseller_name","order_detail.product_name","order_detail.show_url","order_detail.official_price","order_detail.cost_price","order_detail.create_time","order_detail.update_time",
"order_cash.channel","order_cash.cash_activity_id","order_cash.receive_status","order_cash.receive_time","order_cash.cash_packet_id","order_cash.cash_id","order_cash.amount","order_cash.status","order_cash.expire_time","order_cash.update_time",
"order_voucher.channel","order_voucher.channel_activity_id","order_voucher.channel_voucher_id","order_voucher.status","order_voucher.grant_time","order_voucher.usage_time","order_voucher.refund_time","order_voucher.status_modify_time","order_voucher.overdue_time","order_voucher.refund_amount","order_voucher.official_price","order_voucher.out_biz_no","order_voucher.account_no",
"plan.id","plan.title","plan.status","plan.begin_time","plan.end_time",
"key_batch.id","key_batch.batch_name","key_batch.bind_object","key_batch.quantity","key_batch.stock","key_batch.begin_time","key_batch.end_time",
"code_batch.id","code_batch.title","code_batch.status","code_batch.begin_time","code_batch.end_time","code_batch.quantity","code_batch.usage","code_batch.stock",
"voucher.channel","voucher.channel_activity_id","voucher.price","voucher.balance","voucher.used_amount","voucher.denomination",
"voucher_batch.channel_activity_id","voucher_batch.temp_no","voucher_batch.provider","voucher_batch.weight",
"merchant_key_send.merchant_id","merchant_key_send.out_biz_no","merchant_key_send.key","merchant_key_send.status","merchant_key_send.usage_time","merchant_key_send.create_time"
]
}

View File

@ -0,0 +1,144 @@
# MarketingSystemDataTool 需求文档与测试点
## 背景与目标
- 为营销系统与易码通系统提供统一、可配置的数据导出能力CSV/XLSX以模板化方式生成安全、可控的查询与导出文件。
- 支持模板管理、EXPLAIN 评估、分批与时间分片执行、进度追踪与文件下载,满足运营与数据分析的批量导出场景。
## 范围
- 后端Go 单服务HTTP API连接两套 MySQL营销、易码通不含消息队列与缓存。
- 前端Vue 3 + Element PlusCDN单页工具页面模板维护与导出发起、任务查看与下载。
- 存储:本地 `storage/export/` 目录生成分片文件与最终 zip导出记录与模板元数据存库。
## 系统架构
- 路由注册:`server/internal/api/router.go:11`、`server/internal/api/router.go:13`、`server/internal/api/router.go:15`、`server/internal/api/router.go:16-27`;静态文件:`server/internal/api/router.go:40`。
- 启动入口:`server/cmd/server/main.go:15-46`,加载配置、初始化日志、连接 MySQL、启动 HTTP Server。
- 配置加载:`server/internal/config/config.go:31-69`YAML + 环境变量DSN 组合:`server/internal/config/config.go:94-99`。
- 响应封装与中间件:`server/internal/api/response.go:18-36`、`server/internal/api/middleware.go:18-31`TraceID/CORS/访问日志)。
- SQL 构建与评估:`server/internal/exporter/sqlbuilder.go`、`server/internal/exporter/explain.go`、`server/internal/exporter/evaluate.go`、`server/internal/exporter/writer.go`。
- Schema 映射与白名单:`server/internal/schema/*.go`;字段标签:`server/internal/api/templates.go:315-317`。
- 前端页面与逻辑:`web/index.html`、`web/main.js`。
## 功能需求
### 模板管理
- 新增模板:`POST /api/templates`,字段包含名称、数据源、场景主表、字段集合、默认筛选、输出格式、可见性、归属人。
- 列表与详情:`GET /api/templates`、`GET /api/templates/{id}`,支持 `userId` 过滤公共/个人模板;展示字段数、执行次数、最近校验。
- 编辑模板:`PATCH /api/templates/{id}`,支持名称、数据源、主表、字段、筛选、格式、可见性、启用状态变更。
- 删除模板:`DELETE /api/templates/{id}`,若存在任务引用禁止删除。
- 模板校验:`POST /api/templates/{id}/validate`,生成 SQL、执行 EXPLAIN、评分与索引建议写回模板。
### 导出执行
- 发起导出:`POST /api/exports`,加载模板→构建 SQL→EXPLAIN 评分(阈值 60→估算行数→入库任务→异步执行。
- 进度与列表:`GET /api/exports` 分页返回任务,含评估摘要与行数;`GET /api/exports/{id}` 返回详细信息与文件列表。
- SQL 查看:`GET /api/exports/{id}/sql` 返回参数化 SQL 与最终展开 SQL。
- 取消与下载:`POST /api/exports/{id}/cancel`、`GET /api/exports/{id}/download` 下载最新 zip。
### 元数据与辅助
- 字段元数据:`GET /api/metadata/fields?datasource&order_type`,返回表与字段、推荐字段集合。
- 选择项:营销侧 `GET /api/creators`、`GET /api/resellers?creator`、`GET /api/plans?reseller`;易码通 `GET /api/ymt/users`、`GET /api/ymt/merchants?user_id`、`GET /api/ymt/activities?merchant_id`。
- Key 解析工具:`GET /api/utils/decode_key?v=...`。
### 权限与安全
- 强制权限条件:营销库必须提供 `creator_in``create_time_between`;易码通通过 `creator_in`(映射为 `user_id`)。
- 字段白名单:禁用 `SELECT *` 与非白名单字段;模板字段、过滤项统一白名单校验。
- 参数化查询:所有筛选采用预编译参数;禁止字符串拼接。
- 敏感字段处理:`order.key` 在易码通解密(`SM4``YMT_KEY_DECRYPT_KEY_B64`),营销系统做逆编码;下载统一 `zip`
### 文件生成与存储
- 输出格式:`csv` 与 `xlsx`;首行写列名;统一 UTF-8。
- 分批与分片:每批查询 `1000` 行;单文件上限 `300000` 行自动切片支持按时间范围10 天步长)拆分执行。
- 记录文件:每个分片与最终 zip 写入 `export_job_files`,保留行数与大小。
### 前端交互
- 模板管理与执行:`web/index.html`、`web/main.js`;通过 `userId` 透传归属过滤Cascader 选择场景字段;对话框尺寸可调整。
- 执行表单:必填时间范围;营销侧创建者/分销商/计划级联;易码通用户/客户/活动级联;订单类型附加筛选。
- 任务列表:每秒轮询;展示评估状态、行数、进度百分比、下载与 SQL 分析。
## 非功能需求
- 性能EXPLAIN 评分达标≥60避免 `ALL`、`Using temporary/filesort`;优先覆盖权限与时间索引。
- 稳定性:分批拉取与时间分片;进度每 50 行刷新;失败/取消状态管理;磁盘 I/O 控制。
- 安全:仅白名单字段;敏感信息通过环境变量注入;日志不打印明文密码与完整 DSN。
- 监控与日志:统一结构化访问日志、错误日志携带 TraceID 与 SQL任务与模板操作记录。
## 接口规范(摘要)
- `POST /api/templates`
- 请求:`{ name, datasource, main_table, fields: string[], filters: object, file_format, visibility, owner_id }`
- 响应:`201 ok`
- `GET /api/templates[?userId]` → 列表;`GET /api/templates/{id}` → 详情
- `PATCH /api/templates/{id}` → 部分更新
- `DELETE /api/templates/{id}` → 模板未被引用时允许删除
- `POST /api/templates/{id}/validate``{ score, suggestions[] }`
- `POST /api/exports`
- 请求:`{ template_id, requested_by, permission, options, file_format, filters, datasource }`
- 响应:`{ id }`(任务 ID
- `GET /api/exports[?template_id&page&page_size&userId]``{ items, total, page, page_size }`
- `GET /api/exports/{id}` → 任务详情与文件列表
- `GET /api/exports/{id}/sql``{ sql, final_sql }`
- `POST /api/exports/{id}/cancel`、`GET /api/exports/{id}/download`
- `GET /api/metadata/fields?datasource&order_type``{ tables[], recommended[] }`
## 配置与环境
- YAML`server/config.yaml`(或根 `config.yaml`),示例端口 `8077`;两套 DB 连接参数与易码通密钥。
- 环境变量覆盖:`MARKETING_DB_*`、`YMT_DB_*`、`YMT_KEY_DECRYPT_KEY_B64`;支持 `.env.local` 注入。
- DSN 模板:`<user>:<password>@tcp(<host>:<port>)/<dbname>?parseTime=True&loc=Local&charset=utf8mb4`。
## 数据与字段(摘要)
- 主表:营销 `order`;易码通主表映射 `order_info`
- 常用过滤:`creator_in`、`create_time_between`、`type_eq`、`out_trade_no_eq`、`plan_id_eq`、`reseller_id_eq` 等。
- 关联表:营销(`order_detail`、`order_cash`、`order_voucher`、`plan`、`key_batch`、`code_batch`、`voucher`、`voucher_batch`、`merchant_key_send`);易码通(`order_cash`、`order_voucher`、`order_digit`、`goods_voucher_batch`、`goods_voucher_subject_config`、`merchant`、`activity`)。
## 关键流程(文字版)
1. 前端创建模板或选择已有模板,选择数据源、场景与字段,设置默认订单类型。
2. 发起导出:前端提交必填的时间范围与权限范围(创建者等),后端生成 SQL → EXPLAIN → 阈值校验。
3. 任务入库与异步执行:分批分页查询,每 1000 行拉取,单文件 `300k` 行分片,时间范围大时按 10 天拆分。
4. 行写入:写 CSV/XLSX特定字段转换`order.key` 解密/逆编码);定期更新进度。
5. 完成:汇总分片为 zip写文件记录任务置为 `completed`,提供下载与 SQL 分析。
## 边界与限制
- 仅支持订单主表(营销 `order`,易码通 `order_info`),不支持自由主表选择。
- 必须包含权限与时间过滤;否则构建器报错或评估不通过。
- 单文件分片上限 `300k` 行;极大数据量建议按时间拆分或筛选范围收窄。
## 测试点
### 单元测试
- SQL 构建器(`server/internal/exporter/sqlbuilder.go`
- 字段白名单与非法字段拒绝。
- 主表校验:仅 `order` / `order_info`
- 过滤解析:`creator_in`(多类型数组)、`create_time_between`(长度=2、其他等值过滤。
- 易码通字段映射与特殊枚举列(`type/status/pay_status` CASE 逻辑)。
- JOIN 选择与避免笛卡尔积(不同过滤与字段组合)。
- EXPLAIN 评估(`server/internal/exporter/explain.go`、`evaluate.go`
- 出现 `ALL`/高 `rows`/`Using temporary/filesort` 的扣分逻辑与建议生成。
- 评分阈值判定与错误分支(禁止执行)。
- 写入器(`server/internal/exporter/writer.go`
- CSV/XLSX 写列名与行;`Close` 返回路径与大小;异常路径处理。
- 行转换(`server/internal/api/exports.go:868-887`
- `order.key` 解密/逆编码:易码通 SM4密钥为空与有效、营销逆编码后长度与字符集校验。
- 时间分片(`server/internal/api/exports.go:731-751`
- 输入合法性与步长切分边界;起止相等、跨天与不足步长。
### 集成测试
- 模板生命周期:创建→校验→编辑→列表/详情→删除(被引用场景拒绝)。
- 导出端到端:发起(营销与易码通各 1 套→下载→SQL 分析;大窗口下分片与 zip 生成验证。
- 列表分页与过滤:`/api/exports` `page/page_size/template_id/userId` 组合下返回一致性。
- 前端交互:字段级联选择、对话框动态尺寸、`userId` 透传与权限限制(编辑/删除按钮显隐)。
### 性能测试
- EXPLAIN 评分边界:构造不同索引覆盖与过滤条件,验证评分变化与建议内容。
- 大数据量导出:行数 10 万/30 万/100 万分批写入,评估速度、文件大小与 zip 打包时间;进度刷新频率与准确性。
### 安全测试
- 非白名单字段与非法主表拒绝SQL 注入尝试(字符串拼接禁止)。
- 敏感信息:`order.key` 转换正确且日志不泄漏;配置与环境变量加载不写入日志明文密码。
- 下载鉴权:仅通过最新文件下载 API404 场景与异常路径。
### 前端测试
- 表单校验:模板创建必填项、导出必填时间范围与权限范围。
- 选择项联动:创建者→分销商→计划(营销);用户→客户→活动(易码通)。
- 任务轮询进度计算、完成后下载按钮显隐SQL 查看正确展示最终 SQL。
## 验收标准
- 模板创建、校验、编辑、删除流程完整可用;字段白名单生效。
- 导出任务在营销与易码通数据源下均可成功执行EXPLAIN 评分≥60能生成 zip 文件并下载。
- 在大窗口与分片场景下保持稳定,进度与行数统计准确;日志与响应携带 TraceID。
- 前端交互顺畅必填项校验与权限控制符合预期SQL 分析与下载功能可用。

38
scripts/deploy_docker.sh Normal file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ENV_NAME="${1:-test}"
IMAGE="marketing-system-data-tool"
TAG="$ENV_NAME"
PORT="${PORT:-8077}"
cd "$ROOT_DIR"
if docker image inspect "$IMAGE:$TAG" >/dev/null 2>&1; then
echo "使用已有镜像作为缓存: $IMAGE:$TAG"
DOCKER_BUILDKIT=1 docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from "$IMAGE:$TAG" -t "$IMAGE:$TAG" -f Dockerfile .
else
echo "镜像不存在,开始构建: $IMAGE:$TAG"
DOCKER_BUILDKIT=1 docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t "$IMAGE:$TAG" -f Dockerfile .
fi
mkdir -p log storage/export
CID_NAME="marketing-data-$ENV_NAME"
RUNNING=$(docker inspect -f '{{.State.Running}}' "$CID_NAME" 2>/dev/null || echo false)
if [ "$RUNNING" = "true" ]; then
docker stop "$CID_NAME" >/dev/null 2>&1 || true
fi
if docker ps -a --format '{{.Names}}' | grep -q "^${CID_NAME}$"; then
docker rm "$CID_NAME" >/dev/null 2>&1 || true
fi
CONFIG_PATH="$ROOT_DIR/server/config.yaml"
if [ ! -f "$CONFIG_PATH" ]; then
echo "配置文件缺失:$CONFIG_PATH" >&2
exit 1
fi
docker run -d \
--name "$CID_NAME" \
--restart unless-stopped \
-p "$PORT:8077" \
-v "$ROOT_DIR/storage/export:/app/storage/export" \
-v "$ROOT_DIR/log:/app/log" \
-v "$CONFIG_PATH:/app/server/config.yaml:ro" \
"$IMAGE:$TAG"
echo "container: $CID_NAME image: $IMAGE:$TAG port: $PORT"

BIN
server/.DS_Store vendored Normal file

Binary file not shown.

63
server/cmd/server/main.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"log"
"net/http"
"os"
"server/internal/api"
"server/internal/config"
"server/internal/db"
"server/internal/logging"
"time"
)
func main() {
cfg := config.Load()
if cfg.YMTKeyDecryptKeyB64 != "" {
os.Setenv("YMT_KEY_DECRYPT_KEY_B64", cfg.YMTKeyDecryptKeyB64)
}
_ = logging.Init("log")
log.Println("connecting YMT MySQL:", cfg.YMTDB.Host+":"+cfg.YMTDB.Port, "db", cfg.YMTDB.Name, "user", cfg.YMTDB.User)
log.Println("connecting Marketing MySQL:", cfg.MarketingDB.Host+":"+cfg.MarketingDB.Port, "db", cfg.MarketingDB.Name, "user", cfg.MarketingDB.User)
log.Println("connecting Meta MySQL (templates/jobs):", cfg.YMTTestDB.Host+":"+cfg.YMTTestDB.Port, "db", cfg.YMTTestDB.Name, "user", cfg.YMTTestDB.User)
if cfg.YMTDB.DSN() == "" {
log.Fatal("YMT MySQL DSN missing: host, port, user, name required")
}
if cfg.MarketingDB.DSN() == "" {
log.Fatal("Marketing MySQL DSN missing: host, port, user, name required")
}
if cfg.YMTTestDB.DSN() == "" {
log.Fatal("Meta MySQL DSN missing: host, port, user, name required")
}
ymt, err := db.ConnectMySQL(cfg.YMTDB.DSN())
if err != nil {
log.Fatal(err)
}
// apply pool settings from env for YMT
db.ApplyPoolFromEnv(ymt, "YMT_DB_")
marketing, err := db.ConnectMySQL(cfg.MarketingDB.DSN())
if err != nil {
log.Fatal(err)
}
// apply pool settings from env for Marketing
db.ApplyPoolFromEnv(marketing, "MARKETING_DB_")
meta, err := db.ConnectMySQL(cfg.YMTTestDB.DSN())
if err != nil {
log.Fatal(err)
}
// apply pool settings from env for Meta (templates/jobs)
db.ApplyPoolFromEnv(meta, "YMT_TEST_DB_")
r := api.NewRouter(meta, marketing, ymt)
addr := ":" + func() string {
s := cfg.Port
if s == "" {
return "8077"
}
return s
}()
srv := &http.Server{Addr: addr, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 60 * time.Second}
log.Println("server listening on ", addr)
log.Fatal(srv.ListenAndServe())
}
// buildDSN deprecated; use cfg.YMTDB.DSN()/cfg.MarketingDB.DSN()

View File

@ -0,0 +1,15 @@
app:
port: "8077"
marketing_db:
host: "YOUR_MARKETING_DB_HOST"
port: "3306"
user: "YOUR_MARKETING_DB_USER"
password: "YOUR_MARKETING_DB_PASSWORD"
name: "YOUR_MARKETING_DB_NAME"
ymt_db:
host: "YOUR_YMT_DB_HOST"
port: "3306"
user: "YOUR_YMT_DB_USER"
password: "YOUR_YMT_DB_PASSWORD"
name: "YOUR_YMT_DB_NAME"
ymt_key_decrypt_key_b64: ""

21
server/go.mod Normal file
View File

@ -0,0 +1,21 @@
module server
go 1.21
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/xuri/excelize/v2 v2.8.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

104
server/go.sum Normal file
View File

@ -0,0 +1,104 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
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_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -0,0 +1,52 @@
package api
import (
"net/http"
"time"
"server/internal/logging"
)
type statusWriter struct{
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int){
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte)(int, error){
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func withAccess(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
h.ServeHTTP(sw, r)
dur := time.Since(start)
m := MetaFrom(r)
logging.JSON("INFO", map[string]interface{}{
"kind": "access",
"trace_id": TraceIDFrom(r),
"method": m.Method,
"path": m.Path,
"query": m.Query,
"remote": m.Remote,
"status": sw.status,
"bytes": sw.bytes,
"duration_ms": dur.Milliseconds(),
})
})
}

View File

@ -0,0 +1,105 @@
package api
import (
"database/sql"
"net/http"
"strconv"
"strings"
)
type CreatorsAPI struct {
marketing *sql.DB
}
func CreatorsHandler(marketing *sql.DB) http.Handler {
api := &CreatorsAPI{marketing: marketing}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/creators")
if r.Method == http.MethodGet && p == "" {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *CreatorsAPI) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
sql1 := "SELECT DISTINCT user_id, COALESCE(user_name, '') AS name FROM activity WHERE user_id IS NOT NULL"
args := []interface{}{}
if q != "" {
sql1 += " AND (CAST(user_id AS CHAR) LIKE ? OR user_name LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like)
}
sql1 += " ORDER BY user_id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.marketing.Query(sql1, args...)
out := []map[string]interface{}{}
if err == nil {
defer rows.Close()
for rows.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
m := map[string]interface{}{"id": id.Int64, "name": name.String}
out = append(out, m)
}
}
if err != nil || len(out) == 0 {
sqlPlan := "SELECT DISTINCT creator, COALESCE(creator_name, '') AS name FROM plan WHERE creator IS NOT NULL"
argsPlan := []interface{}{}
if q != "" {
sqlPlan += " AND (CAST(creator AS CHAR) LIKE ? OR creator_name LIKE ?)"
like := "%" + q + "%"
argsPlan = append(argsPlan, like, like)
}
sqlPlan += " ORDER BY creator ASC LIMIT ?"
argsPlan = append(argsPlan, limit)
rowsPlan, errPlan := a.marketing.Query(sqlPlan, argsPlan...)
if errPlan == nil {
defer rowsPlan.Close()
tmp := []map[string]interface{}{}
for rowsPlan.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rowsPlan.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
tmp = append(tmp, map[string]interface{}{"id": id.Int64, "name": name.String})
}
if len(tmp) > 0 { out = tmp }
}
if len(out) == 0 {
sql2 := "SELECT DISTINCT creator, '' AS name FROM `order` WHERE creator IS NOT NULL"
args2 := []interface{}{}
if q != "" {
sql2 += " AND CAST(creator AS CHAR) LIKE ?"
args2 = append(args2, "%"+q+"%")
}
sql2 += " ORDER BY creator ASC LIMIT ?"
args2 = append(args2, limit)
rows2, err2 := a.marketing.Query(sql2, args2...)
if err2 != nil {
fail(w, r, http.StatusInternalServerError, err2.Error())
return
}
defer rows2.Close()
out = out[:0]
for rows2.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows2.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
m := map[string]interface{}{"id": id.Int64, "name": name.String}
out = append(out, m)
}
}
}
ok(w, r, out)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
package api
import (
"database/sql"
"net/http"
"sort"
)
func MetadataHandler(meta, marketing, ymt *sql.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ds := r.URL.Query().Get("datasource")
ot := r.URL.Query().Get("order_type")
db := marketing
if ds == "ymt" {
db = ymt
}
tables := []string{}
if ds == "ymt" {
tables = []string{"order_info", "order_cash", "order_voucher", "order_digit", "goods_voucher_batch", "goods_voucher_subject_config", "merchant", "activity"}
} else {
tables = []string{"order", "order_detail", "order_cash", "order_voucher", "plan", "key_batch", "code_batch", "voucher", "voucher_batch", "merchant_key_send"}
}
out := []map[string]interface{}{}
for _, tbl := range tables {
cols := getColumns(db, tbl)
fields := []map[string]string{}
for _, c := range cols {
tCanonical, fCanonical := canonicalField(ds, tbl, c.Name)
if tCanonical == "" || fCanonical == "" {
continue
}
lab := c.Comment
if lab == "" {
lab = fCanonical
}
fields = append(fields, map[string]string{"key": tCanonical + "." + fCanonical, "field": fCanonical, "label": lab})
}
tDisplay := displayTable(ds, tbl)
out = append(out, map[string]interface{}{"table": tDisplay, "label": tableLabel(tDisplay), "fields": fields})
}
sort.Slice(out, func(i, j int) bool { return out[i]["table"].(string) < out[j]["table"].(string) })
rec := recommendedDefaults(ds, ot)
ok(w, r, map[string]interface{}{"datasource": ds, "tables": out, "recommended": rec})
})
}
func tableLabel(t string) string {
switch t {
case "order":
return "订单主表"
case "order_detail":
return "订单详情"
case "order_cash":
return "红包订单"
case "order_voucher":
return "立减金订单"
case "order_digit":
return "直充卡密订单"
case "plan":
return "活动"
case "key_batch":
return "key批次"
case "code_batch":
return "兑换码批次"
case "voucher":
return "立减金"
case "voucher_batch":
return "立减金批次"
case "merchant_key_send":
return "key码API发放记录"
case "goods_voucher_batch":
return "立减金批次表"
case "goods_voucher_subject_config":
return "立减金主体"
case "merchant":
return "客户"
case "activity":
return "活动"
default:
return t
}
}
func displayTable(ds, tbl string) string {
if ds == "ymt" && tbl == "order_info" {
return "order"
}
return tbl
}
func canonicalField(ds, tbl, col string) (string, string) {
if ds == "ymt" && tbl == "order_info" {
switch col {
case "order_no":
return "order", "order_number"
case "key_code":
return "order", "key"
case "user_id":
return "order", "creator"
case "out_order_no":
return "order", "out_trade_no"
case "activity_id":
return "order", "plan_id"
case "merchant_id":
return "order", "reseller_id"
case "goods_id":
return "order", "product_id"
case "pay_price":
return "order", "pay_amount"
case "key_batch_name":
return "order", "key_batch_id"
default:
return "order", col
}
}
// other tables: canonical equals actual
return tbl, col
}
type columnMeta struct {
Name string
Comment string
}
func getColumns(db *sql.DB, tbl string) []columnMeta {
rows, err := db.Query("SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ORDINAL_POSITION", tbl)
if err != nil {
return []columnMeta{}
}
defer rows.Close()
cols := []columnMeta{}
for rows.Next() {
var name, comment string
if err := rows.Scan(&name, &comment); err == nil {
cols = append(cols, columnMeta{Name: name, Comment: comment})
}
}
return cols
}
func recommendedDefaults(ds, orderType string) []string {
base := []string{"order.order_number", "order.creator", "order.out_trade_no", "order.type", "order.status", "order.contract_price", "order.num", "order.pay_amount", "order.create_time"}
if ds != "ymt" {
base = []string{"order.order_number", "order.creator", "order.out_trade_no", "order.type", "order.status", "order.contract_price", "order.num", "order.total", "order.pay_amount", "order.create_time"}
}
t := orderType
if t == "2" { // 直充卡密
if ds == "ymt" {
base = append(base, "order_digit.order_no", "order_digit.account", "order_digit.success_time")
} else {
base = append(base, "plan.title")
}
} else if t == "3" { // 立减金
if ds == "ymt" {
base = append(base, "order_voucher.channel", "order_voucher.status", "goods_voucher_batch.channel_batch_no")
} else {
base = append(base, "order_voucher.channel", "voucher.denomination")
}
} else if t == "1" { // 红包
if ds == "ymt" {
base = append(base, "order_cash.channel", "order_cash.receive_status", "order_cash.denomination")
} else {
base = append(base, "order_cash.channel", "order_cash.receive_status", "order_cash.amount")
}
}
return base
}

View File

@ -0,0 +1,84 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
)
type ctxKey string
var traceKey ctxKey = "trace_id"
var sqlKey ctxKey = "sql"
var metaKey ctxKey = "req_meta"
var payloadKey ctxKey = "payload"
func withTrace(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tid := r.Header.Get("X-Request-ID")
if tid == "" {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
tid = hex.EncodeToString(buf)
}
w.Header().Set("X-Request-ID", tid)
m := ReqMeta{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Remote: r.RemoteAddr}
ctx := context.WithValue(r.Context(), traceKey, tid)
ctx = context.WithValue(ctx, metaKey, m)
h.ServeHTTP(w, r.WithContext(ctx))
})
}
func TraceIDFrom(r *http.Request) string {
v := r.Context().Value(traceKey)
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
func WithSQL(r *http.Request, sql string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), sqlKey, sql))
}
func SQLFrom(r *http.Request) string {
v := r.Context().Value(sqlKey)
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
type ReqMeta struct {
Method string
Path string
Query string
Remote string
}
func MetaFrom(r *http.Request) ReqMeta {
v := r.Context().Value(metaKey)
if v == nil {
return ReqMeta{}
}
m, _ := v.(ReqMeta)
return m
}
func WithPayload(r *http.Request, v interface{}) *http.Request {
b, _ := json.Marshal(v)
return r.WithContext(context.WithValue(r.Context(), payloadKey, string(b)))
}
func PayloadFrom(r *http.Request) string {
v := r.Context().Value(payloadKey)
if v == nil {
return ""
}
s, _ := v.(string)
return s
}

View File

@ -0,0 +1,75 @@
package api
import (
"database/sql"
"net/http"
"strconv"
"strings"
)
type PlansAPI struct {
marketing *sql.DB
}
func PlansHandler(marketing *sql.DB) http.Handler {
api := &PlansAPI{marketing: marketing}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/plans")
if r.Method == http.MethodGet && p == "" {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *PlansAPI) list(w http.ResponseWriter, r *http.Request) {
resellerParam := r.URL.Query().Get("reseller")
if resellerParam == "" {
resellerParam = r.URL.Query().Get("reseller_id")
}
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
resellers := []string{}
for _, s := range strings.Split(resellerParam, ",") {
s = strings.TrimSpace(s)
if s != "" { resellers = append(resellers, s) }
}
if len(resellers) == 0 {
ok(w, r, []map[string]interface{}{})
return
}
ph := strings.Repeat("?,", len(resellers))
ph = strings.TrimSuffix(ph, ",")
sql1 := "SELECT id, COALESCE(title,'') AS title FROM plan WHERE reseller_id IN (" + ph + ")"
args := []interface{}{}
for _, v := range resellers { args = append(args, v) }
if q != "" {
sql1 += " AND (CAST(id AS CHAR) LIKE ? OR title LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like)
}
sql1 += " ORDER BY id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.marketing.Query(sql1, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id sql.NullInt64
var title sql.NullString
if err := rows.Scan(&id, &title); err != nil { continue }
if !id.Valid { continue }
m := map[string]interface{}{"id": id.Int64, "title": title.String}
out = append(out, m)
}
ok(w, r, out)
}

View File

@ -0,0 +1,72 @@
package api
import (
"database/sql"
"net/http"
"strconv"
"strings"
)
type ResellersAPI struct {
marketing *sql.DB
}
func ResellersHandler(marketing *sql.DB) http.Handler {
api := &ResellersAPI{marketing: marketing}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/resellers")
if r.Method == http.MethodGet && p == "" {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *ResellersAPI) list(w http.ResponseWriter, r *http.Request) {
creatorsParam := r.URL.Query().Get("creator")
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
creators := []string{}
for _, s := range strings.Split(creatorsParam, ",") {
s = strings.TrimSpace(s)
if s != "" { creators = append(creators, s) }
}
if len(creators) == 0 {
ok(w, r, []map[string]interface{}{})
return
}
ph := strings.Repeat("?,", len(creators))
ph = strings.TrimSuffix(ph, ",")
sql1 := "SELECT DISTINCT reseller_id, COALESCE(reseller_name,'') AS name FROM plan WHERE reseller_id IS NOT NULL AND creator IN (" + ph + ")"
args := []interface{}{}
for _, c := range creators { args = append(args, c) }
if q != "" {
sql1 += " AND (CAST(reseller_id AS CHAR) LIKE ? OR reseller_name LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like)
}
sql1 += " ORDER BY reseller_id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.marketing.Query(sql1, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
m := map[string]interface{}{"id": id.Int64, "name": name.String}
out = append(out, m)
}
ok(w, r, out)
}

View File

@ -0,0 +1,51 @@
package api
import (
"encoding/json"
"log"
"net/http"
"path/filepath"
"runtime"
)
type resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
TraceID string `json:"trace_id"`
}
func writeJSON(w http.ResponseWriter, r *http.Request, status int, code int, msg string, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
tid := TraceIDFrom(r)
b, _ := json.Marshal(resp{Code: code, Msg: msg, Data: data, TraceID: tid})
w.Write(b)
if code != 0 {
_, file, line, _ := runtime.Caller(1)
base := filepath.Base(file)
sql := SQLFrom(r)
meta := MetaFrom(r)
payload := PayloadFrom(r)
if sql != "" {
log.Printf("trace_id=%s status=%d file=%s:%d method=%s path=%s query=%s remote=%s sql=%s payload=%s msg=%s", tid, status, base, line, meta.Method, meta.Path, meta.Query, meta.Remote, sql, payload, msg)
} else {
log.Printf("trace_id=%s status=%d file=%s:%d method=%s path=%s query=%s remote=%s payload=%s msg=%s", tid, status, base, line, meta.Method, meta.Path, meta.Query, meta.Remote, payload, msg)
}
}
}
func ok(w http.ResponseWriter, r *http.Request, data interface{}) {
writeJSON(w, r, http.StatusOK, 0, "ok", data)
}
func fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
writeJSON(w, r, status, 1, msg, nil)
}
func failCat(w http.ResponseWriter, r *http.Request, status int, msg string, kind string) {
writeJSON(w, r, status, 1, msg, nil)
tid := TraceIDFrom(r)
meta := MetaFrom(r)
log.Printf("kind=%s trace_id=%s status=%d method=%s path=%s msg=%s", kind, tid, status, meta.Method, meta.Path, msg)
}

View File

@ -0,0 +1,52 @@
package api
import (
"database/sql"
"net/http"
"os"
)
func NewRouter(metaDB *sql.DB, marketingDB *sql.DB, ymtDB *sql.DB) http.Handler {
mux := http.NewServeMux()
mux.Handle("/api/templates", withAccess(withTrace(TemplatesHandler(metaDB, marketingDB))))
mux.Handle("/api/templates/", withAccess(withTrace(TemplatesHandler(metaDB, marketingDB))))
mux.Handle("/api/exports", withAccess(withTrace(ExportsHandler(metaDB, marketingDB, ymtDB))))
mux.Handle("/api/exports/", withAccess(withTrace(ExportsHandler(metaDB, marketingDB, ymtDB))))
mux.Handle("/api/metadata/fields", withAccess(withTrace(MetadataHandler(metaDB, marketingDB, ymtDB))))
mux.Handle("/api/creators", withAccess(withTrace(CreatorsHandler(marketingDB))))
mux.Handle("/api/creators/", withAccess(withTrace(CreatorsHandler(marketingDB))))
mux.Handle("/api/resellers", withAccess(withTrace(ResellersHandler(marketingDB))))
mux.Handle("/api/resellers/", withAccess(withTrace(ResellersHandler(marketingDB))))
mux.Handle("/api/plans", withAccess(withTrace(PlansHandler(marketingDB))))
mux.Handle("/api/plans/", withAccess(withTrace(PlansHandler(marketingDB))))
mux.Handle("/api/ymt/users", withAccess(withTrace(YMTUsersHandler(ymtDB))))
mux.Handle("/api/ymt/users/", withAccess(withTrace(YMTUsersHandler(ymtDB))))
mux.Handle("/api/ymt/merchants", withAccess(withTrace(YMTMerchantsHandler(ymtDB))))
mux.Handle("/api/ymt/merchants/", withAccess(withTrace(YMTMerchantsHandler(ymtDB))))
mux.Handle("/api/ymt/activities", withAccess(withTrace(YMTActivitiesHandler(ymtDB))))
mux.Handle("/api/ymt/activities/", withAccess(withTrace(YMTActivitiesHandler(ymtDB))))
mux.HandleFunc("/api/utils/decode_key", func(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("v")
if v == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing v"))
return
}
d := decodeOrderKey(v)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"decoded\":\"" + d + "\"}"))
})
sd := staticDir()
mux.Handle("/", http.FileServer(http.Dir(sd)))
return mux
}
func staticDir() string {
if _, err := os.Stat("web/index.html"); err == nil {
return "web"
}
if _, err := os.Stat("../web/index.html"); err == nil {
return "../web"
}
return "web"
}

View File

@ -0,0 +1,328 @@
package api
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"server/internal/exporter"
"server/internal/schema"
"strings"
"time"
)
type TemplatesAPI struct {
meta *sql.DB
marketing *sql.DB
}
func TemplatesHandler(meta, marketing *sql.DB) http.Handler {
api := &TemplatesAPI{meta: meta, marketing: marketing}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/templates")
if r.Method == http.MethodPost && p == "" {
api.createTemplate(w, r)
return
}
if r.Method == http.MethodGet && p == "" {
api.listTemplates(w, r)
return
}
if strings.HasPrefix(p, "/") {
id := strings.TrimPrefix(p, "/")
if r.Method == http.MethodGet {
api.getTemplate(w, r, id)
return
}
if r.Method == http.MethodPatch {
api.patchTemplate(w, r, id)
return
}
if r.Method == http.MethodDelete {
api.deleteTemplate(w, r, id)
return
}
if r.Method == http.MethodPost && strings.HasSuffix(p, "/validate") {
id = strings.TrimSuffix(id, "/validate")
api.validateTemplate(w, r, id)
return
}
}
fail(w, r, http.StatusNotFound, "not found")
})
}
type TemplatePayload struct {
Name string `json:"name"`
Datasource string `json:"datasource"`
MainTable string `json:"main_table"`
Fields []string `json:"fields"`
Filters map[string]interface{} `json:"filters"`
FileFormat string `json:"file_format"`
OwnerID uint64 `json:"owner_id"`
Visibility string `json:"visibility"`
}
func (a *TemplatesAPI) createTemplate(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var p TemplatePayload
json.Unmarshal(b, &p)
r = WithPayload(r, p)
uidStr := r.URL.Query().Get("userId")
if uidStr != "" {
var uid uint64
_, _ = fmt.Sscan(uidStr, &uid)
if uid > 0 {
p.OwnerID = uid
}
}
now := time.Now()
tplSQL := "INSERT INTO export_templates (name, datasource, main_table, fields_json, filters_json, file_format, visibility, owner_id, enabled, stats_enabled, last_validated_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
tplArgs := []interface{}{p.Name, p.Datasource, p.MainTable, toJSON(p.Fields), toJSON(p.Filters), p.FileFormat, p.Visibility, p.OwnerID, 1, 0, now, now, now}
log.Printf("trace_id=%s sql=%s args=%v", TraceIDFrom(r), tplSQL, tplArgs)
_, err := a.meta.Exec(tplSQL, tplArgs...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, r, http.StatusCreated, 0, "ok", nil)
}
func (a *TemplatesAPI) listTemplates(w http.ResponseWriter, r *http.Request) {
uidStr := r.URL.Query().Get("userId")
sqlText := "SELECT id,name,datasource,main_table,file_format,visibility,owner_id,enabled,last_validated_at,created_at,updated_at, COALESCE(JSON_LENGTH(fields_json),0) AS field_count, (SELECT COUNT(1) FROM export_jobs ej WHERE ej.template_id = export_templates.id) AS exec_count FROM export_templates"
args := []interface{}{}
conds := []string{}
if uidStr != "" {
conds = append(conds, "owner_id IN (0, ?)")
args = append(args, uidStr)
}
conds = append(conds, "enabled = 1")
if len(conds) > 0 {
sqlText += " WHERE " + strings.Join(conds, " AND ")
}
sqlText += " ORDER BY datasource ASC, id DESC LIMIT 200"
rows, err := a.meta.Query(sqlText, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id uint64
var name, datasource, mainTable, fileFormat, visibility string
var ownerID uint64
var enabled int
var lastValidatedAt sql.NullTime
var createdAt, updatedAt time.Time
var fieldCount, execCount int64
err := rows.Scan(&id, &name, &datasource, &mainTable, &fileFormat, &visibility, &ownerID, &enabled, &lastValidatedAt, &createdAt, &updatedAt, &fieldCount, &execCount)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
m := map[string]interface{}{"id": id, "name": name, "datasource": datasource, "main_table": mainTable, "file_format": fileFormat, "visibility": visibility, "owner_id": ownerID, "enabled": enabled == 1, "last_validated_at": lastValidatedAt.Time, "created_at": createdAt, "updated_at": updatedAt, "field_count": fieldCount, "exec_count": execCount}
out = append(out, m)
}
ok(w, r, out)
}
func (a *TemplatesAPI) getTemplate(w http.ResponseWriter, r *http.Request, id string) {
row := a.meta.QueryRow("SELECT id,name,datasource,main_table,fields_json,filters_json,file_format,visibility,owner_id,enabled,explain_score,last_validated_at,created_at,updated_at FROM export_templates WHERE id=?", id)
var m = map[string]interface{}{}
var tid uint64
var name, datasource, mainTable, fileFormat, visibility string
var ownerID uint64
var enabled int
var explainScore sql.NullInt64
var lastValidatedAt sql.NullTime
var createdAt, updatedAt time.Time
var fields, filters []byte
err := row.Scan(&tid, &name, &datasource, &mainTable, &fields, &filters, &fileFormat, &visibility, &ownerID, &enabled, &explainScore, &lastValidatedAt, &createdAt, &updatedAt)
if err != nil {
fail(w, r, http.StatusNotFound, "not found")
return
}
m["id"] = tid
m["name"] = name
m["datasource"] = datasource
m["main_table"] = mainTable
m["file_format"] = fileFormat
m["visibility"] = visibility
m["owner_id"] = ownerID
m["enabled"] = enabled == 1
m["explain_score"] = explainScore.Int64
m["last_validated_at"] = lastValidatedAt.Time
m["created_at"] = createdAt
m["updated_at"] = updatedAt
m["fields"] = fromJSON(fields)
m["filters"] = fromJSON(filters)
ok(w, r, m)
}
func (a *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, id string) {
b, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("trace_id=%s error reading request body: %v", TraceIDFrom(r), err)
fail(w, r, http.StatusBadRequest, "invalid request body")
return
}
log.Printf("trace_id=%s patchTemplate request body: %s", TraceIDFrom(r), string(b))
var p map[string]interface{}
err = json.Unmarshal(b, &p)
if err != nil {
log.Printf("trace_id=%s error unmarshaling request body: %v", TraceIDFrom(r), err)
fail(w, r, http.StatusBadRequest, "invalid JSON format")
return
}
log.Printf("trace_id=%s patchTemplate parsed payload: %v", TraceIDFrom(r), p)
log.Printf("trace_id=%s patchTemplate template ID: %s", TraceIDFrom(r), id)
set := []string{}
args := []interface{}{}
for k, v := range p {
log.Printf("trace_id=%s patchTemplate processing field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
switch k {
case "name", "visibility", "file_format", "main_table":
if strVal, ok := v.(string); ok {
set = append(set, k+"=?")
args = append(args, strVal)
log.Printf("trace_id=%s patchTemplate added string field: %s, value: %s", TraceIDFrom(r), k, strVal)
} else {
log.Printf("trace_id=%s patchTemplate invalid string field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
}
case "fields":
set = append(set, "fields_json=?")
jsonBytes := toJSON(v)
args = append(args, jsonBytes)
log.Printf("trace_id=%s patchTemplate added fields_json: %s", TraceIDFrom(r), string(jsonBytes))
case "filters":
set = append(set, "filters_json=?")
jsonBytes := toJSON(v)
args = append(args, jsonBytes)
log.Printf("trace_id=%s patchTemplate added filters_json: %s", TraceIDFrom(r), string(jsonBytes))
case "enabled":
set = append(set, "enabled=?")
if boolVal, ok := v.(bool); ok {
if boolVal {
args = append(args, 1)
} else {
args = append(args, 0)
}
log.Printf("trace_id=%s patchTemplate added enabled: %t", TraceIDFrom(r), boolVal)
} else {
log.Printf("trace_id=%s patchTemplate invalid bool field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
}
}
}
if len(set) == 0 {
log.Printf("trace_id=%s patchTemplate no fields to update", TraceIDFrom(r))
fail(w, r, http.StatusBadRequest, "no patch")
return
}
// ensure updated_at
set = append(set, "updated_at=?")
now := time.Now()
args = append(args, now, id)
sql := "UPDATE export_templates SET " + strings.Join(set, ",") + " WHERE id= ?"
log.Printf("trace_id=%s patchTemplate executing SQL: %s", TraceIDFrom(r), sql)
log.Printf("trace_id=%s patchTemplate SQL args: %v", TraceIDFrom(r), args)
_, err = a.meta.Exec(sql, args...)
if err != nil {
log.Printf("trace_id=%s patchTemplate SQL error: %v", TraceIDFrom(r), err)
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
log.Printf("trace_id=%s patchTemplate update successful", TraceIDFrom(r))
ok(w, r, nil)
}
func (a *TemplatesAPI) deleteTemplate(w http.ResponseWriter, r *http.Request, id string) {
var cnt int64
row := a.meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id=?", id)
_ = row.Scan(&cnt)
if cnt > 0 {
soft := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("soft")))
if soft == "1" || soft == "true" || soft == "yes" {
a.meta.Exec("UPDATE export_templates SET enabled=?, updated_at=? WHERE id= ?", 0, time.Now(), id)
ok(w, r, nil)
return
}
fail(w, r, http.StatusBadRequest, "template in use")
return
}
_, err := a.meta.Exec("DELETE FROM export_templates WHERE id= ?", id)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
ok(w, r, nil)
}
func (a *TemplatesAPI) validateTemplate(w http.ResponseWriter, r *http.Request, id string) {
row := a.meta.QueryRow("SELECT datasource, main_table, fields_json, filters_json FROM export_templates WHERE id= ?", id)
var ds string
var main string
var fields, filters []byte
err := row.Scan(&ds, &main, &fields, &filters)
if err != nil {
fail(w, r, http.StatusNotFound, "not found")
return
}
var fs []string
var fl map[string]interface{}
json.Unmarshal(fields, &fs)
json.Unmarshal(filters, &fl)
wl := Whitelist()
req := exporter.BuildRequest{MainTable: main, Datasource: ds, Fields: fs, Filters: fl}
q, args, err := exporter.BuildSQL(req, wl)
if err != nil {
failCat(w, r, http.StatusBadRequest, err.Error(), "sql_build_error")
return
}
dataDB := a.selectDataDB(ds)
score, sugg, err := exporter.EvaluateExplain(dataDB, q, args)
if err != nil {
failCat(w, r, http.StatusBadRequest, err.Error(), "explain_error")
return
}
idxSugg := exporter.IndexSuggestions(req)
sugg = append(sugg, idxSugg...)
_, _ = a.meta.Exec("UPDATE export_templates SET explain_json=?, explain_score=?, last_validated_at=?, updated_at=? WHERE id=?", toJSON(map[string]interface{}{"sql": q, "suggestions": sugg}), score, time.Now(), time.Now(), id)
ok(w, r, map[string]interface{}{"score": score, "suggestions": sugg})
}
func (a *TemplatesAPI) selectDataDB(ds string) *sql.DB {
if ds == "ymt" {
return a.meta
}
return a.marketing
}
func toJSON(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
func fromJSON(b []byte) interface{} {
var v interface{}
json.Unmarshal(b, &v)
return v
}
func Whitelist() map[string]bool { return schema.AllWhitelist() }
func FieldLabels() map[string]string {
return schema.AllLabels()
}

View File

@ -0,0 +1,62 @@
package api
import (
"database/sql"
"net/http"
"strconv"
)
type YMTActivitiesAPI struct {
ymt *sql.DB
}
func YMTActivitiesHandler(ymt *sql.DB) http.Handler {
api := &YMTActivitiesAPI{ymt: ymt}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *YMTActivitiesAPI) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
merchantIDStr := q.Get("merchant_id")
like := q.Get("q")
limitStr := q.Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
sql1 := "SELECT id, name FROM activity WHERE id IS NOT NULL"
args := []interface{}{}
if merchantIDStr != "" {
sql1 += " AND merchant_id = ?"
args = append(args, merchantIDStr)
}
if like != "" {
sql1 += " AND (CAST(id AS CHAR) LIKE ? OR name LIKE ?)"
s := "%" + like + "%"
args = append(args, s, s)
}
sql1 += " ORDER BY id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.ymt.Query(sql1, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
out = append(out, map[string]interface{}{"id": id.Int64, "name": name.String})
}
ok(w, r, out)
}

View File

@ -0,0 +1,62 @@
package api
import (
"database/sql"
"net/http"
"strconv"
)
type YMTMerchantsAPI struct {
ymt *sql.DB
}
func YMTMerchantsHandler(ymt *sql.DB) http.Handler {
api := &YMTMerchantsAPI{ymt: ymt}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *YMTMerchantsAPI) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
userIDStr := q.Get("user_id")
like := q.Get("q")
limitStr := q.Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
sql1 := "SELECT id, name FROM merchant WHERE id IS NOT NULL"
args := []interface{}{}
if userIDStr != "" {
sql1 += " AND user_id = ?"
args = append(args, userIDStr)
}
if like != "" {
sql1 += " AND (CAST(id AS CHAR) LIKE ? OR name LIKE ?)"
s := "%" + like + "%"
args = append(args, s, s)
}
sql1 += " ORDER BY id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.ymt.Query(sql1, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
out = append(out, map[string]interface{}{"id": id.Int64, "name": name.String})
}
ok(w, r, out)
}

View File

@ -0,0 +1,57 @@
package api
import (
"database/sql"
"net/http"
"strconv"
"strings"
)
type YMTUsersAPI struct {
ymt *sql.DB
}
func YMTUsersHandler(ymt *sql.DB) http.Handler {
api := &YMTUsersAPI{ymt: ymt}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/ymt/users")
if r.Method == http.MethodGet && p == "" {
api.list(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
})
}
func (a *YMTUsersAPI) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 2000
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 10000 { limit = n }
}
sql1 := "SELECT DISTINCT user_id, COALESCE(user_name, '') AS name FROM activity WHERE user_id IS NOT NULL"
args := []interface{}{}
if q != "" {
sql1 += " AND (CAST(user_id AS CHAR) LIKE ? OR user_name LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like)
}
sql1 += " ORDER BY user_id ASC LIMIT ?"
args = append(args, limit)
rows, err := a.ymt.Query(sql1, args...)
if err != nil {
fail(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []map[string]interface{}{}
for rows.Next() {
var id sql.NullInt64
var name sql.NullString
if err := rows.Scan(&id, &name); err != nil { continue }
if !id.Valid { continue }
out = append(out, map[string]interface{}{"id": id.Int64, "name": name.String})
}
ok(w, r, out)
}

View File

@ -0,0 +1,152 @@
package config
import (
"io"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type DB struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Name string `yaml:"name"`
}
type App struct {
Port string `yaml:"port"`
MarketingDB DB `yaml:"marketing_db"`
YMTDB DB `yaml:"ymt_db"`
YMTTestDB DB `yaml:"ymt_test_db"`
YMTKeyDecryptKeyB64 string `yaml:"ymt_key_decrypt_key_b64"`
}
type root struct {
App App `yaml:"app"`
}
func Load() App {
var cfg App
// unified single config: prefer server/config.yaml, then config.yaml
if !readYAML(filepath.Join("server", "config.yaml"), &cfg) {
_ = readYAML("config.yaml", &cfg)
}
LoadEnv()
if v := os.Getenv("MARKETING_DB_HOST"); v != "" {
cfg.MarketingDB.Host = v
}
if v := os.Getenv("MARKETING_DB_PORT"); v != "" {
cfg.MarketingDB.Port = v
}
if v := os.Getenv("MARKETING_DB_USER"); v != "" {
cfg.MarketingDB.User = v
}
if v := os.Getenv("MARKETING_DB_PASSWORD"); v != "" {
cfg.MarketingDB.Password = v
}
if v := os.Getenv("MARKETING_DB_NAME"); v != "" {
cfg.MarketingDB.Name = v
}
if v := os.Getenv("YMT_DB_HOST"); v != "" {
cfg.YMTDB.Host = v
}
if v := os.Getenv("YMT_DB_PORT"); v != "" {
cfg.YMTDB.Port = v
}
if v := os.Getenv("YMT_DB_USER"); v != "" {
cfg.YMTDB.User = v
}
if v := os.Getenv("YMT_DB_PASSWORD"); v != "" {
cfg.YMTDB.Password = v
}
if v := os.Getenv("YMT_DB_NAME"); v != "" {
cfg.YMTDB.Name = v
}
// optional env overrides for test DB
if v := os.Getenv("YMT_TEST_DB_HOST"); v != "" {
cfg.YMTTestDB.Host = v
}
if v := os.Getenv("YMT_TEST_DB_PORT"); v != "" {
cfg.YMTTestDB.Port = v
}
if v := os.Getenv("YMT_TEST_DB_USER"); v != "" {
cfg.YMTTestDB.User = v
}
if v := os.Getenv("YMT_TEST_DB_PASSWORD"); v != "" {
cfg.YMTTestDB.Password = v
}
if v := os.Getenv("YMT_TEST_DB_NAME"); v != "" {
cfg.YMTTestDB.Name = v
}
return cfg
}
func readYAML(path string, out *App) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return false
}
var r root
if err := yaml.Unmarshal(b, &r); err == nil {
if r.App.Port != "" || r.App.MarketingDB.Host != "" || r.App.YMTDB.Host != "" {
*out = r.App
return true
}
}
if err := yaml.Unmarshal(b, out); err != nil {
return false
}
return true
}
func (d DB) DSN() string {
if strings.TrimSpace(d.User) == "" || strings.TrimSpace(d.Host) == "" || strings.TrimSpace(d.Port) == "" || strings.TrimSpace(d.Name) == "" {
return ""
}
return d.User + ":" + d.Password + "@tcp(" + d.Host + ":" + d.Port + ")/" + d.Name + "?parseTime=True&loc=Local&charset=utf8mb4"
}
func LoadEnv() {
// optional local env override only
paths := []string{
".env.local",
filepath.Join("server", ".env.local"),
}
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
continue
}
b, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
lines := strings.Split(string(b), "\n")
for _, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
kv := strings.SplitN(s, "=", 2)
if len(kv) != 2 {
continue
}
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if k != "" {
os.Setenv(k, v)
}
}
break
}
}

View File

@ -0,0 +1,18 @@
package db
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func ConnectMySQL(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,34 @@
package db
import (
"database/sql"
"os"
"strconv"
"time"
)
func ApplyPoolFromEnv(d *sql.DB, prefix string) {
if d == nil {
return
}
if v := os.Getenv(prefix + "MAX_OPEN_CONNS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
d.SetMaxOpenConns(n)
}
}
if v := os.Getenv(prefix + "MAX_IDLE_CONNS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
d.SetMaxIdleConns(n)
}
}
if v := os.Getenv(prefix + "CONN_MAX_LIFETIME"); v != "" {
if dur, err := time.ParseDuration(v); err == nil {
d.SetConnMaxLifetime(dur)
}
}
if v := os.Getenv(prefix + "CONN_MAX_IDLE_TIME"); v != "" {
if dur, err := time.ParseDuration(v); err == nil {
d.SetConnMaxIdleTime(dur)
}
}
}

View File

@ -0,0 +1,76 @@
package exporter
import (
"database/sql"
"fmt"
"strings"
"server/internal/schema"
)
func EvaluateExplain(db *sql.DB, q string, args []interface{}) (int, []string, error) {
rows, score, err := RunExplain(db, q, args)
if err != nil { return 0, nil, err }
sugg := []string{}
for _, r := range rows {
// tbl := r.Table.String
typ := r.Type.String
if typ == "" && r.SelectType.Valid { typ = r.SelectType.String }
if typ == "ALL" {
sugg = append(sugg, "出现全表扫描(ALL),请在过滤或连接列上建立索引")
}
if r.Extra.Valid {
e := r.Extra.String
if contains(e, "Using temporary") || contains(e, "Using filesort") {
sugg = append(sugg, "出现临时表或文件排序,请优化排序列及索引覆盖")
}
}
}
return score, sugg, nil
}
func IndexSuggestions(req BuildRequest) []string {
sugg := []string{}
sch := schema.Get(req.Datasource, req.MainTable)
// Filter-based suggestions
has := func(k string) bool { _, ok := req.Filters[k]; return ok }
add := func(s string){ if s != "" { sugg = append(sugg, s) } }
if has("creator_in") && has("create_time_between") {
add(fmt.Sprintf("建议在 `%s`(create_time, %s) 建立复合索引以覆盖权限与时间范围", sch.TableName("order"), colName(sch, "creator_in")))
} else {
if has("creator_in") { add(fmt.Sprintf("建议在 `%s`(%s) 建立索引以覆盖权限过滤", sch.TableName("order"), colName(sch, "creator_in"))) }
if has("create_time_between") { add(fmt.Sprintf("建议在 `%s`(create_time) 建立索引以覆盖时间范围", sch.TableName("order"))) }
}
if has("plan_id_eq") { add(fmt.Sprintf("建议在 `%s`(%s) 建立索引以覆盖活动/计划过滤", sch.TableName("order"), colName(sch, "plan_id_eq"))) }
if has("reseller_id_eq") { add(fmt.Sprintf("建议在 `%s`(%s) 建立索引以覆盖分销商过滤", sch.TableName("order"), colName(sch, "reseller_id_eq"))) }
if has("product_id_eq") { add(fmt.Sprintf("建议在 `%s`(%s) 建立索引以覆盖商品过滤", sch.TableName("order"), colName(sch, "product_id_eq"))) }
if has("out_trade_no_eq") { add(fmt.Sprintf("建议在 `%s`(%s) 建立索引以覆盖支付流水过滤", sch.TableName("order"), colName(sch, "out_trade_no_eq"))) }
// Table usage-based join suggestions
usedTables := map[string]bool{}
for _, tf := range req.Fields {
parts := strings.Split(tf, ".")
if len(parts)==2 { usedTables[parts[0]] = true }
}
if req.MainTable == "order_info" {
add("建议在 `order_info`(order_no) 建立索引以优化与子表的连接")
if usedTables["order_cash"] { add("建议在 `order_cash`(order_no) 建立索引以优化与主表的连接") }
if usedTables["order_voucher"] { add("建议在 `order_voucher`(order_no) 建立索引以优化与主表的连接") }
if usedTables["order_digit"] { add("建议在 `order_digit`(order_no) 建立索引以优化与主表的连接") }
if usedTables["goods_voucher_batch"] { add("建议在 `goods_voucher_batch`(channel_batch_no) 建立索引以优化与订单立减金的连接") }
if usedTables["goods_voucher_subject_config"] { add("建议在 `goods_voucher_subject_config`(id) 上确保主键索引以优化连接") }
if usedTables["merchant"] { add("建议在 `merchant`(id) 上确保主键索引以优化连接") }
if usedTables["activity"] { add("建议在 `activity`(id) 上确保主键索引以优化连接") }
}
return dedup(sugg)
}
func colName(sch schema.Schema, key string) string {
if _, col, ok := sch.FilterColumn(key); ok { return col }
return ""
}
func dedup(arr []string) []string {
m := map[string]bool{}
out := []string{}
for _, s := range arr { if !m[s] { m[s]=true; out = append(out, s) } }
return out
}

View File

@ -0,0 +1,158 @@
package exporter
import (
"database/sql"
"log"
)
type ExplainRow struct {
ID sql.NullInt64
SelectType sql.NullString
Table sql.NullString
Type sql.NullString
PossibleKeys sql.NullString
Key sql.NullString
KeyLen sql.NullString
Ref sql.NullString
Rows sql.NullInt64
Filtered sql.NullFloat64
Extra sql.NullString
}
func RunExplain(db *sql.DB, q string, args []interface{}) ([]ExplainRow, int, error) {
log.Printf("sql=EXPLAIN %s args=%v", q, args)
rows, err := db.Query("EXPLAIN "+q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
res := []ExplainRow{}
cols, _ := rows.Columns()
for rows.Next() {
vals := make([]interface{}, len(cols))
dest := make([]interface{}, len(cols))
for i := range vals {
dest[i] = &vals[i]
}
if err := rows.Scan(dest...); err != nil {
return nil, 0, err
}
r := ExplainRow{}
if len(cols) >= 10 {
toRow(vals, &r)
}
res = append(res, r)
}
score := 100
for _, r := range res {
if r.Type.String == "ALL" {
score -= 50
}
if r.Rows.Int64 > 1000000 {
score -= 30
}
if r.Extra.Valid {
if contains(r.Extra.String, "Using temporary") || contains(r.Extra.String, "Using filesort") {
score -= 20
}
}
}
if score < 0 {
score = 0
}
return res, score, nil
}
func toRow(vals []interface{}, r *ExplainRow) {
if s, ok := vals[0].([]byte); ok {
r.ID.Int64 = toInt64(string(s))
r.ID.Valid = true
}
if s, ok := vals[1].([]byte); ok {
r.SelectType.String = string(s)
r.SelectType.Valid = true
}
if s, ok := vals[2].([]byte); ok {
r.Table.String = string(s)
r.Table.Valid = true
}
if s, ok := vals[3].([]byte); ok {
r.Type.String = string(s)
r.Type.Valid = true
}
if s, ok := vals[4].([]byte); ok {
r.PossibleKeys.String = string(s)
r.PossibleKeys.Valid = true
}
if s, ok := vals[5].([]byte); ok {
r.Key.String = string(s)
r.Key.Valid = true
}
if s, ok := vals[6].([]byte); ok {
r.KeyLen.String = string(s)
r.KeyLen.Valid = true
}
if s, ok := vals[7].([]byte); ok {
r.Ref.String = string(s)
r.Ref.Valid = true
}
if s, ok := vals[8].([]byte); ok {
r.Rows.Int64 = toInt64(string(s))
r.Rows.Valid = true
}
if s, ok := vals[9].([]byte); ok {
r.Filtered.Float64 = toFloat64(string(s))
r.Filtered.Valid = true
}
if len(vals) > 10 {
if s, ok := vals[10].([]byte); ok {
r.Extra.String = string(s)
r.Extra.Valid = true
}
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func toInt64(s string) int64 {
var n int64
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
continue
}
n = n*10 + int64(c-'0')
}
return n
}
func toFloat64(s string) float64 {
var n float64
var d float64
var seen bool
d = 1
for i := 0; i < len(s); i++ {
c := s[i]
if c == '.' {
seen = true
continue
}
if c < '0' || c > '9' {
continue
}
if !seen {
n = n*10 + float64(c-'0')
} else {
d *= 10
n = n + float64(c-'0')/d
}
}
return n
}

View File

@ -0,0 +1,518 @@
package exporter
import (
"encoding/json"
"errors"
"fmt"
"server/internal/schema"
"strconv"
"strings"
"time"
)
type BuildRequest struct {
MainTable string
Datasource string
Fields []string // table.field
Filters map[string]interface{}
}
func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{}, error) {
if req.MainTable != "order" && req.MainTable != "order_info" {
return "", nil, errors.New("unsupported main table")
}
sch := schema.Get(req.Datasource, req.MainTable)
if req.Datasource == "marketing" && req.MainTable == "order" {
if v, ok := req.Filters["create_time_between"]; ok {
switch t := v.(type) {
case []interface{}:
if len(t) != 2 {
return "", nil, errors.New("create_time_between 需要两个时间值")
}
case []string:
if len(t) != 2 {
return "", nil, errors.New("create_time_between 需要两个时间值")
}
default:
return "", nil, errors.New("create_time_between 格式错误")
}
} else {
return "", nil, errors.New("缺少时间过滤:必须提供 create_time_between")
}
}
cols := []string{}
need := map[string]bool{}
for _, tf := range req.Fields {
// normalize YMT physical names saved previously to logical names
if req.Datasource == "ymt" && strings.HasPrefix(tf, "order_info.") {
tf = strings.Replace(tf, "order_info.", "order.", 1)
}
if whitelist != nil && !whitelist[tf] {
continue
}
parts := strings.Split(tf, ".")
if len(parts) != 2 {
return "", nil, errors.New("invalid field format")
}
t, f := parts[0], parts[1]
if req.Datasource == "marketing" && t == "order_voucher" && f == "channel_batch_no" {
f = "channel_activity_id"
}
if req.Datasource == "ymt" && t == "order_voucher" && f == "channel_activity_id" {
f = "channel_batch_no"
}
need[t] = true
mt := sch.TableName(t)
mf, _ := sch.MapField(t, f)
if req.Datasource == "marketing" && t == "order" && req.MainTable == "order" {
if f == "status" {
cols = append(cols, "CASE `order`.status WHEN 0 THEN '待充值' WHEN 1 THEN '充值中' WHEN 2 THEN '已完成' WHEN 3 THEN '充值失败' WHEN 4 THEN '已取消' WHEN 5 THEN '已过期' WHEN 6 THEN '待支付' END AS `order.status`")
continue
}
if f == "type" {
cols = append(cols, "CASE `order`.type WHEN 1 THEN '直充卡密' WHEN 2 THEN '立减金' WHEN 3 THEN '红包' ELSE '' END AS `order.type`")
continue
}
if f == "pay_type" {
cols = append(cols, "CASE `order`.pay_type WHEN 1 THEN '支付宝' WHEN 5 THEN '微信' ELSE '' END AS `order.pay_type`")
continue
}
if f == "pay_status" {
cols = append(cols, "CASE `order`.pay_status WHEN 1 THEN '待支付' WHEN 2 THEN '已支付' WHEN 3 THEN '已退款' ELSE '' END AS `order.pay_status`")
continue
}
if req.Datasource == "marketing" && f == "card_code" {
cols = append(cols, "CASE WHEN LENGTH(`order`.card_code) > 10 THEN CONCAT(SUBSTRING(`order`.card_code,1,6),'****',SUBSTRING(`order`.card_code, LENGTH(`order`.card_code)-3, 4)) ELSE `order`.card_code END AS `order.card_code`")
continue
}
}
if req.Datasource == "ymt" && t == "order" {
if f == "type" {
cols = append(cols, "CASE `"+mt+"`.type WHEN 1 THEN '红包订单' WHEN 2 THEN '直充卡密订单' WHEN 3 THEN '立减金订单' ELSE '' END AS `order.type`")
continue
}
if f == "status" {
cols = append(cols, "CASE `"+mt+"`.status WHEN 1 THEN '待充值' WHEN 2 THEN '充值中' WHEN 3 THEN '充值成功' WHEN 4 THEN '充值失败' WHEN 5 THEN '已过期' WHEN 6 THEN '已作废' WHEN 7 THEN '已核销' WHEN 8 THEN '核销失败' WHEN 9 THEN '订单重置' WHEN 10 THEN '卡单' ELSE '' END AS `order.status`")
continue
}
if f == "pay_status" {
cols = append(cols, "CASE `"+mt+"`.pay_status WHEN 1 THEN '待支付' WHEN 2 THEN '支付中' WHEN 3 THEN '已支付' WHEN 4 THEN '取消支付' WHEN 5 THEN '退款中' WHEN 6 THEN '退款成功' ELSE '' END AS `order.pay_status`")
continue
}
}
if req.Datasource == "ymt" && t == "activity" {
if f == "settlement_type" {
cols = append(cols, "CASE `"+mt+"`.settlement_type WHEN 1 THEN '发放结算' WHEN 2 THEN '打开结算' WHEN 3 THEN '领用结算' WHEN 4 THEN '核销结算' ELSE '' END AS `activity.settlement_type`")
continue
}
}
if req.Datasource == "ymt" && t == "order_digit" {
if f == "order_type" {
cols = append(cols, "CASE `"+mt+"`.order_type WHEN 1 THEN '直充' WHEN 2 THEN '卡密' ELSE '' END AS `order_digit.order_type`")
continue
}
}
if t == "order_cash" && f == "receive_status" {
cols = append(cols, "CASE `order_cash`.receive_status WHEN 0 THEN '待领取' WHEN 1 THEN '领取中' WHEN 2 THEN '领取成功' WHEN 3 THEN '领取失败' ELSE '' END AS `order_cash.receive_status`")
continue
}
// YMT 的 order_cash 表无 is_confirm 字段,输出占位常量
if req.Datasource == "ymt" && t == "order_cash" && f == "is_confirm" {
cols = append(cols, "0 AS `order_cash.is_confirm`")
continue
}
if t == "order_cash" && f == "channel" {
cols = append(cols, "CASE `order_cash`.channel WHEN 1 THEN '支付宝' WHEN 2 THEN '微信' WHEN 3 THEN '云闪付' ELSE '' END AS `order_cash.channel`")
continue
}
if t == "order_voucher" && f == "channel" {
cols = append(cols, "CASE `order_voucher`.channel WHEN 1 THEN '支付宝' WHEN 2 THEN '微信' WHEN 3 THEN '云闪付' ELSE '' END AS `order_voucher.channel`")
continue
}
if req.Datasource == "ymt" && t == "order_voucher" && f == "status" {
cols = append(cols, "CASE `order_voucher`.status WHEN 1 THEN '待发放' WHEN 2 THEN '发放中' WHEN 3 THEN '发放失败' WHEN 4 THEN '待核销' WHEN 5 THEN '已核销' WHEN 6 THEN '已过期' WHEN 7 THEN '已退款' ELSE '' END AS `order_voucher.status`")
continue
}
if t == "order_voucher" && f == "status" {
cols = append(cols, "CASE `order_voucher`.status WHEN 1 THEN '可用' WHEN 2 THEN '已实扣' WHEN 3 THEN '已过期' WHEN 4 THEN '已退款' WHEN 5 THEN '领取失败' WHEN 6 THEN '发放中' WHEN 7 THEN '部分退款' WHEN 8 THEN '已退回' WHEN 9 THEN '发放失败' ELSE '' END AS `order_voucher.status`")
continue
}
if t == "order_voucher" && f == "receive_mode" {
cols = append(cols, "CASE `order_voucher`.receive_mode WHEN 1 THEN '渠道授权用户id' WHEN 2 THEN '手机号或邮箱' ELSE '' END AS `order_voucher.receive_mode`")
continue
}
if t == "order_voucher" && f == "out_biz_no" {
cols = append(cols, "'' AS `order_voucher.out_biz_no`")
continue
}
// Fallback for YMT tables that are not joined in schema: voucher, voucher_batch, merchant_key_send
if req.Datasource == "ymt" && (t == "voucher" || t == "voucher_batch" || t == "merchant_key_send") {
cols = append(cols, "'' AS `"+t+"."+f+"`")
continue
}
cols = append(cols, "`"+mt+"`."+escape(mf)+" AS `"+t+"."+f+"`")
}
if len(cols) == 0 {
return "", nil, errors.New("no fields")
}
sb := strings.Builder{}
baseCols := strings.Join(cols, ",")
sb.WriteString("SELECT ")
sb.WriteString(baseCols)
sb.WriteString(" FROM `" + sch.TableName(req.MainTable) + "`")
args := []interface{}{}
where := []string{}
// collect need from filters referencing related tables
if _, ok := req.Filters["order_cash_cash_activity_id_eq"]; ok {
need["order_cash"] = true
}
if _, ok := req.Filters["order_voucher_channel_activity_id_eq"]; ok {
need["order_voucher"] = true
}
if _, ok := req.Filters["voucher_batch_channel_activity_id_eq"]; ok {
need["voucher_batch"] = true
need["voucher"] = true
need["order_voucher"] = true
}
if _, ok := req.Filters["merchant_out_biz_no_eq"]; ok {
need["merchant_key_send"] = true
}
if v, ok := req.Filters["creator_in"]; ok {
ids := []interface{}{}
switch t := v.(type) {
case []interface{}:
ids = t
case []int:
for _, x := range t {
ids = append(ids, x)
}
case []string:
for _, x := range t {
ids = append(ids, x)
}
}
if len(ids) > 0 {
ph := strings.Repeat("?,", len(ids))
ph = strings.TrimSuffix(ph, ",")
if tbl, col, ok := sch.FilterColumn("creator_in"); ok {
where = append(where, fmt.Sprintf("`%s`.%s IN (%s)", sch.TableName(tbl), escape(col), ph))
}
args = append(args, ids...)
}
}
if v, ok := req.Filters["create_time_between"]; ok {
var arr []interface{}
b, _ := json.Marshal(v)
json.Unmarshal(b, &arr)
if len(arr) != 2 {
return "", nil, errors.New("create_time_between requires 2 values")
}
if tbl, col, ok := sch.FilterColumn("create_time_between"); ok {
where = append(where, fmt.Sprintf("`%s`.%s BETWEEN ? AND ?", sch.TableName(tbl), escape(col)))
}
args = append(args, arr[0], arr[1])
}
if v, ok := req.Filters["type_eq"]; ok {
var tv int
switch t := v.(type) {
case float64:
tv = int(t)
case int:
tv = t
case string:
// simple digits parsing and label mapping
_s := strings.TrimSpace(t)
for i := 0; i < len(_s); i++ {
c := _s[i]
if c < '0' || c > '9' {
continue
}
tv = tv*10 + int(c-'0')
}
if tv == 0 {
if req.Datasource == "ymt" {
if _s == "红包订单" {
tv = 1
}
if _s == "直充卡密订单" {
tv = 2
}
if _s == "立减金订单" {
tv = 3
}
} else {
if _s == "直充卡密" {
tv = 1
}
if _s == "立减金" {
tv = 2
}
if _s == "红包" {
tv = 3
}
}
}
}
if tv == 1 || tv == 2 || tv == 3 {
if tbl, col, ok := sch.FilterColumn("type_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, tv)
}
}
if v, ok := req.Filters["out_trade_no_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("out_trade_no_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["account_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("account_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["plan_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("plan_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["key_batch_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("key_batch_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["product_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("product_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["reseller_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("reseller_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["code_batch_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("code_batch_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["order_cash_cash_activity_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("order_cash_cash_activity_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["order_voucher_channel_activity_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("order_voucher_channel_activity_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
if v, ok := req.Filters["voucher_batch_channel_activity_id_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("voucher_batch_channel_activity_id_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
args = append(args, s)
}
}
}
if v, ok := req.Filters["merchant_out_biz_no_eq"]; ok {
s := toString(v)
if s != "" {
if tbl, col, ok := sch.FilterColumn("merchant_out_biz_no_eq"); ok {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
}
args = append(args, s)
}
}
// append necessary joins after collecting filter-driven needs
joins := sch.BuildJoins(need, req.MainTable)
for _, j := range joins {
sb.WriteString(j)
}
if len(where) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(where, " AND "))
}
needDedupe := req.Datasource == "marketing" && req.MainTable == "order" && (need["order_voucher"] || need["voucher"] || need["voucher_batch"] || need["key_batch"] || need["code_batch"])
if needDedupe {
// Extract alias names in order
aliases := make([]string, 0, len(cols))
sources := make([]string, 0, len(cols))
for _, c := range cols {
pos := strings.LastIndex(c, " AS `")
if pos < 0 {
continue
}
alias := c[pos+5:]
if len(alias) == 0 {
continue
}
if alias[len(alias)-1] == '`' {
alias = alias[:len(alias)-1]
}
aliases = append(aliases, alias)
parts := strings.Split(alias, ".")
src := ""
if len(parts) >= 1 {
src = parts[0]
}
sources = append(sources, src)
}
mt := sch.TableName(req.MainTable)
pkCol, _ := sch.MapField(req.MainTable, "order_number")
var out strings.Builder
out.WriteString("SELECT ")
for i := range aliases {
if i > 0 {
out.WriteString(",")
}
alias := aliases[i]
src := sources[i]
if src == "order" {
out.WriteString("sub.`" + alias + "`")
} else {
out.WriteString("MIN(sub.`" + alias + "`) AS `" + alias + "`")
}
}
out.WriteString(" FROM (")
out.WriteString(sb.String())
out.WriteString(") AS sub GROUP BY sub.`" + mt + "." + pkCol + "`")
return out.String(), args, nil
}
return sb.String(), args, nil
}
func escape(s string) string {
if s == "key" {
return "`key`"
}
if s == "index" {
return "`index`"
}
return s
}
func toString(v interface{}) string {
switch t := v.(type) {
case []byte:
return string(t)
case string:
return t
case int64:
return strconv.FormatInt(t, 10)
case int:
return strconv.Itoa(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case time.Time:
return t.Format("2006-01-02 15:04:05")
default:
return ""
}
}
// BuildCountSQL: minimal COUNT for filters-only joins, counting distinct main PK to avoid 1:N duplication
func BuildCountSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{}, error) {
if req.MainTable != "order" && req.MainTable != "order_info" {
return "", nil, errors.New("unsupported main table")
}
sch := schema.Get(req.Datasource, req.MainTable)
mt := sch.TableName(req.MainTable)
pkCol, _ := sch.MapField(req.MainTable, "order_number")
args := []interface{}{}
where := []string{}
need := map[string]bool{}
// mark joins only needed by filters
for k, _ := range req.Filters {
if tbl, _, ok := sch.FilterColumn(k); ok {
need[tbl] = true
}
}
// build WHERE from filters
for k, v := range req.Filters {
if tbl, col, ok := sch.FilterColumn(k); ok {
switch k {
case "creator_in":
ids := []interface{}{}
switch t := v.(type) {
case []interface{}:
ids = t
case []int:
for _, x := range t {
ids = append(ids, x)
}
case []string:
for _, x := range t {
ids = append(ids, x)
}
}
if len(ids) > 0 {
ph := strings.Repeat("?,", len(ids))
ph = strings.TrimSuffix(ph, ",")
where = append(where, fmt.Sprintf("`%s`.%s IN (%s)", sch.TableName(tbl), escape(col), ph))
args = append(args, ids...)
}
case "create_time_between":
var arr []interface{}
b, _ := json.Marshal(v)
_ = json.Unmarshal(b, &arr)
if len(arr) == 2 {
where = append(where, fmt.Sprintf("`%s`.%s BETWEEN ? AND ?", sch.TableName(tbl), escape(col)))
args = append(args, arr[0], arr[1])
}
default:
s := toString(v)
if s != "" {
where = append(where, fmt.Sprintf("`%s`.%s = ?", sch.TableName(tbl), escape(col)))
args = append(args, s)
}
}
}
}
sb := strings.Builder{}
sb.WriteString("SELECT COUNT(DISTINCT `" + mt + "`." + pkCol + ") FROM `" + mt + "`")
for _, j := range sch.BuildJoins(need, req.MainTable) {
sb.WriteString(j)
}
if len(where) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(where, " AND "))
}
return sb.String(), args, nil
}

View File

@ -0,0 +1,427 @@
package exporter
import (
"database/sql"
"log"
"server/internal/logging"
"server/internal/schema"
"strings"
"time"
)
type CursorSQL struct{ ds, main, mt, tsCol, pkCol string }
func NewCursorSQL(ds, main string) *CursorSQL {
sch := schema.Get(ds, main)
mt := sch.TableName(main)
ts, _ := sch.MapField(main, "create_time")
pk, _ := sch.MapField(main, "order_number")
return &CursorSQL{ds: ds, main: main, mt: mt, tsCol: ts, pkCol: pk}
}
func (c *CursorSQL) InjectSelect(base string) string {
idx := strings.Index(base, " FROM ")
if idx <= 0 {
return base
}
u := strings.ToUpper(base)
prefix := "SELECT "
if strings.HasPrefix(u, "SELECT DISTINCT ") {
prefix = "SELECT DISTINCT "
} else if strings.HasPrefix(u, "SELECT SQL_NO_CACHE ") {
prefix = "SELECT SQL_NO_CACHE "
}
return prefix + "`" + c.mt + "`." + c.tsCol + " AS __ts, `" + c.mt + "`." + c.pkCol + " AS __pk, " + base[len(prefix):]
}
func (c *CursorSQL) AddOrder(base string) string {
return base + " ORDER BY `" + c.mt + "`." + c.tsCol + ", `" + c.mt + "`." + c.pkCol
}
func (c *CursorSQL) AddCursor(base string) string {
u := strings.ToUpper(base)
cond := " AND ((`" + c.mt + "`." + c.tsCol + ") > ? OR ((`" + c.mt + "`." + c.tsCol + ") = ? AND (`" + c.mt + "`." + c.pkCol + ") > ?))"
if strings.Contains(u, " WHERE ") {
return base + cond
}
return base + strings.TrimPrefix(cond, " AND ")
}
func CountRows(db *sql.DB, base string, args []interface{}) int64 {
u := strings.ToUpper(base)
idx := strings.Index(u, " FROM ")
cut := len(base)
if idx > 0 {
for _, tok := range []string{" ORDER BY ", " LIMIT ", " OFFSET "} {
if p := strings.Index(u[idx:], tok); p >= 0 {
cp := idx + p
if cp < cut {
cut = cp
}
}
}
}
minimal := base
if idx > 0 {
seg := base[idx:cut]
minimal = "SELECT 1" + seg
}
q := "SELECT COUNT(1) FROM (" + minimal + ") AS sub"
row := db.QueryRow(q, args...)
var c int64
if err := row.Scan(&c); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "count_error", "error": err.Error(), "sql": q, "args": args})
log.Printf("count_error sql=%s args=%v err=%v", q, args, err)
return 0
}
return c
}
func CountRowsFast(db *sql.DB, ds, main string, filters map[string]interface{}) int64 {
sch := schema.Get(ds, main)
mt := sch.TableName(main)
q := "SELECT COUNT(1) FROM `" + mt + "` WHERE 1=1"
args := []interface{}{}
addIn := func(col string, v interface{}) {
switch t := v.(type) {
case []interface{}:
if len(t) == 0 {
return
}
ph := make([]string, len(t))
for i := range t {
ph[i] = "?"
args = append(args, t[i])
}
q += " AND `" + col + "` IN (" + strings.Join(ph, ",") + ")"
case []string:
if len(t) == 0 {
return
}
ph := make([]string, len(t))
for i := range t {
ph[i] = "?"
args = append(args, t[i])
}
q += " AND `" + col + "` IN (" + strings.Join(ph, ",") + ")"
case []int:
if len(t) == 0 {
return
}
ph := make([]string, len(t))
for i := range t {
ph[i] = "?"
args = append(args, t[i])
}
q += " AND `" + col + "` IN (" + strings.Join(ph, ",") + ")"
case []int64:
if len(t) == 0 {
return
}
ph := make([]string, len(t))
for i := range t {
ph[i] = "?"
args = append(args, t[i])
}
q += " AND `" + col + "` IN (" + strings.Join(ph, ",") + ")"
}
}
for k, v := range filters {
tbl, col, ok := sch.FilterColumn(k)
if !ok {
continue
}
if tbl != "order" {
continue
}
switch k {
case "creator_in":
addIn(col, v)
case "create_time_between":
switch t := v.(type) {
case []interface{}:
if len(t) == 2 {
q += " AND `" + col + "` BETWEEN ? AND ?"
args = append(args, t[0], t[1])
}
case []string:
if len(t) == 2 {
q += " AND `" + col + "` BETWEEN ? AND ?"
args = append(args, t[0], t[1])
}
}
default:
q += " AND `" + col + "` = ?"
args = append(args, v)
}
}
row := db.QueryRow(q, args...)
var c int64
if err := row.Scan(&c); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "count_fast_error", "error": err.Error(), "sql": q, "args": args})
log.Printf("count_fast_error sql=%s args=%v err=%v", q, args, err)
return 0
}
return c
}
func CountRowsFastChunked(db *sql.DB, ds, main string, filters map[string]interface{}) int64 {
start := ""
end := ""
if v, ok := filters["create_time_between"]; ok {
switch t := v.(type) {
case []interface{}:
if len(t) == 2 {
start = toString(t[0])
end = toString(t[1])
}
case []string:
if len(t) == 2 {
start = t[0]
end = t[1]
}
}
}
if start == "" || end == "" {
return CountRowsFast(db, ds, main, filters)
}
ranges := splitDays(start, end, 15)
var total int64
for _, rg := range ranges {
fl := map[string]interface{}{}
for k, v := range filters {
fl[k] = v
}
fl["create_time_between"] = []string{rg[0], rg[1]}
total += CountRowsFast(db, ds, main, fl)
}
return total
}
func splitDays(startStr, endStr string, stepDays int) [][2]string {
layout := "2006-01-02 15:04:05"
s := strings.TrimSpace(startStr)
e := strings.TrimSpace(endStr)
st, err1 := time.Parse(layout, s)
en, err2 := time.Parse(layout, e)
if err1 != nil || err2 != nil || !en.After(st) || stepDays <= 0 {
return [][2]string{{s, e}}
}
var out [][2]string
cur := st
step := time.Duration(stepDays) * 24 * time.Hour
for cur.Before(en) {
nxt := cur.Add(step)
if nxt.After(en) {
nxt = en
}
out = append(out, [2]string{cur.Format(layout), nxt.Format(layout)})
cur = nxt
}
return out
}
type RowTransform func([]string) []string
type RollCallback func(path string, size int64, partRows int64) error
type ProgressCallback func(totalRows int64) error
func StreamWithCursor(db *sql.DB, base string, args []interface{}, cur *CursorSQL, batch int, cols []string, newWriter func() (RowWriter, error), transform RowTransform, maxRowsPerFile int64, onRoll RollCallback, onProgress ProgressCallback) (int64, []string, error) {
w, err := newWriter()
if err != nil {
return 0, nil, err
}
_ = w.WriteHeader(cols)
if onProgress != nil {
_ = onProgress(0)
}
out := make([]interface{}, len(cols)+2)
dest := make([]interface{}, len(cols)+2)
for i := range out {
dest[i] = &out[i]
}
var total int64
var part int64
var tick int64
files := []string{}
lastTs := ""
lastPk := ""
for {
q2 := cur.InjectSelect(base)
if lastTs != "" || lastPk != "" {
q2 = cur.AddCursor(q2)
}
q2 = cur.AddOrder(q2) + " LIMIT ?"
args2 := append([]interface{}{}, args...)
if lastTs != "" || lastPk != "" {
args2 = append(args2, lastTs, lastTs, lastPk)
}
args2 = append(args2, batch)
rows, e := db.Query(q2, args2...)
if e != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "cursor_query_error", "sql": q2, "args": args2, "error": e.Error()})
log.Printf("cursor_query_error sql=%s args=%v err=%v", q2, args2, e)
// fallback to LIMIT/OFFSET pagination when cursor query fails
_ = rows
_, _, _ = w.Close()
return pagedOffset(db, base, args, batch, cols, newWriter, transform, maxRowsPerFile, onRoll, onProgress)
}
fetched := false
for rows.Next() {
fetched = true
if e := rows.Scan(dest...); e != nil {
rows.Close()
// fallback to LIMIT/OFFSET when scan fails (likely column mismatch)
logging.JSON("ERROR", map[string]interface{}{"event": "cursor_scan_error", "error": e.Error()})
log.Printf("cursor_scan_error err=%v", e)
_, _, _ = w.Close()
return pagedOffset(db, base, args, batch, cols, newWriter, transform, maxRowsPerFile, onRoll, onProgress)
}
vals := make([]string, len(cols))
for i := 0; i < len(cols); i++ {
// skip the injected cursor columns (__ts, __pk) at positions 0 and 1
idx := i + 2
if b, ok := out[idx].([]byte); ok {
vals[i] = string(b)
} else if out[idx] == nil {
vals[i] = ""
} else {
vals[i] = toString(out[idx])
}
}
if transform != nil {
vals = transform(vals)
}
_ = w.WriteRow(vals)
total++
part++
tick++
// update cursor state from injected columns
lastTs = toString(out[0])
lastPk = toString(out[1])
if onProgress != nil && (tick == 1 || tick%200 == 0) {
_ = onProgress(total)
logging.JSON("INFO", map[string]interface{}{"event": "progress_tick", "total_rows": total})
}
if part >= maxRowsPerFile {
p, sz, _ := w.Close()
files = append(files, p)
if onRoll != nil {
_ = onRoll(p, sz, part)
}
w, e = newWriter()
if e != nil {
rows.Close()
return total, files, e
}
_ = w.WriteHeader(cols)
part = 0
}
}
rows.Close()
if !fetched {
break
}
}
p, sz, _ := w.Close()
if part > 0 || len(files) == 0 {
files = append(files, p)
if onRoll != nil {
_ = onRoll(p, sz, part)
}
}
if onProgress != nil {
_ = onProgress(total)
}
return total, files, nil
}
// pagedOffset provides a robust fallback using LIMIT/OFFSET without cursor columns
func pagedOffset(db *sql.DB, base string, args []interface{}, batch int, cols []string, newWriter func() (RowWriter, error), transform RowTransform, maxRowsPerFile int64, onRoll RollCallback, onProgress ProgressCallback) (int64, []string, error) {
w, err := newWriter()
if err != nil {
return 0, nil, err
}
_ = w.WriteHeader(cols)
if onProgress != nil {
_ = onProgress(0)
}
files := []string{}
var total int64
var part int64
var tick int64
for off := 0; ; off += batch {
q := "SELECT * FROM (" + base + ") AS sub LIMIT ? OFFSET ?"
args2 := append(append([]interface{}{}, args...), batch, off)
rows, e := db.Query(q, args2...)
if e != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "offset_query_error", "sql": q, "args": args2, "error": e.Error()})
log.Printf("offset_query_error sql=%s args=%v err=%v", q, args2, e)
return total, files, e
}
fetched := false
out := make([]interface{}, len(cols))
dest := make([]interface{}, len(cols))
for i := range out {
dest[i] = &out[i]
}
for rows.Next() {
fetched = true
if e := rows.Scan(dest...); e != nil {
rows.Close()
logging.JSON("ERROR", map[string]interface{}{"event": "offset_scan_error", "error": e.Error()})
log.Printf("offset_scan_error err=%v", e)
return total, files, e
}
vals := make([]string, len(cols))
for i := 0; i < len(cols); i++ {
if b, ok := out[i].([]byte); ok {
vals[i] = string(b)
} else if out[i] == nil {
vals[i] = ""
} else {
vals[i] = toString(out[i])
}
}
if transform != nil {
vals = transform(vals)
}
_ = w.WriteRow(vals)
total++
part++
tick++
if onProgress != nil && (tick == 1 || tick%200 == 0) {
_ = onProgress(total)
logging.JSON("INFO", map[string]interface{}{"event": "progress_tick", "total_rows": total})
}
if part >= maxRowsPerFile {
p, sz, _ := w.Close()
files = append(files, p)
if onRoll != nil {
_ = onRoll(p, sz, part)
}
w, e = newWriter()
if e != nil {
rows.Close()
return total, files, e
}
_ = w.WriteHeader(cols)
part = 0
}
}
rows.Close()
if !fetched {
break
}
}
p, sz, _ := w.Close()
if part > 0 || len(files) == 0 {
files = append(files, p)
if onRoll != nil {
_ = onRoll(p, sz, part)
}
}
if onProgress != nil {
_ = onProgress(total)
}
return total, files, nil
}

View File

@ -0,0 +1,49 @@
package exporter
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// ZipFiles 将分片文件打包为zip并返回路径与大小同时清理源xlsx分片文件
func ZipFiles(jobID uint64, files []string) (string, int64) {
baseDir := "storage/export"
_ = os.MkdirAll(baseDir, 0755)
zipPath := filepath.Join(baseDir, fmt.Sprintf("job_%d_%d.zip", jobID, time.Now().Unix()))
zf, err := os.Create(zipPath)
if err != nil {
return zipPath, 0
}
defer zf.Close()
zw := zip.NewWriter(zf)
for _, p := range files {
f, err := os.Open(p)
if err != nil {
continue
}
w, err := zw.Create(filepath.Base(p))
if err != nil {
f.Close()
continue
}
_, _ = io.Copy(w, f)
f.Close()
}
_ = zw.Close()
st, err := os.Stat(zipPath)
if err != nil {
return zipPath, 0
}
// 清理xlsx分片文件以节省空间
for _, fp := range files {
if strings.HasSuffix(strings.ToLower(fp), ".xlsx") {
_ = os.Remove(fp)
}
}
return zipPath, st.Size()
}

View File

@ -0,0 +1,157 @@
package exporter
import (
"bufio"
"encoding/csv"
"os"
"path/filepath"
"time"
"github.com/xuri/excelize/v2"
)
type RowWriter interface {
WriteHeader(cols []string) error
WriteRow(vals []string) error
Close() (string, int64, error)
}
type CSVWriter struct {
f *os.File
buf *bufio.Writer
w *csv.Writer
count int64
}
func NewCSVWriter(dir, name string) (*CSVWriter, error) {
os.MkdirAll(dir, 0755)
p := filepath.Join(dir, name+"_"+time.Now().Format("20060102150405")+".csv")
f, err := os.Create(p)
if err != nil {
return nil, err
}
buf := bufio.NewWriterSize(f, 4<<20)
return &CSVWriter{f: f, buf: buf, w: csv.NewWriter(buf)}, nil
}
func (c *CSVWriter) WriteHeader(cols []string) error {
if err := c.w.Write(cols); err != nil {
return err
}
c.count++
return nil
}
func (c *CSVWriter) WriteRow(vals []string) error {
if err := c.w.Write(vals); err != nil {
return err
}
c.count++
return nil
}
func (c *CSVWriter) Close() (string, int64, error) {
c.w.Flush()
if c.buf != nil {
_ = c.buf.Flush()
}
p := c.f.Name()
info, _ := c.f.Stat()
c.f.Close()
return p, info.Size(), nil
}
type XLSXWriter struct {
f *excelize.File
sheet string
row int
path string
sw *excelize.StreamWriter
}
func NewXLSXWriter(dir, name, sheet string) (*XLSXWriter, error) {
os.MkdirAll(dir, 0755)
p := filepath.Join(dir, name+"_"+time.Now().Format("20060102150405")+".xlsx")
f := excelize.NewFile()
idx, err := f.GetSheetIndex(sheet)
if err != nil || idx < 0 {
idx, _ = f.NewSheet(sheet)
f.SetActiveSheet(idx)
if sheet != "Sheet1" {
_ = f.DeleteSheet("Sheet1")
}
} else {
f.SetActiveSheet(idx)
}
sw, e := f.NewStreamWriter(sheet)
if e != nil {
return nil, e
}
return &XLSXWriter{f: f, sheet: sheet, row: 1, path: p, sw: sw}, nil
}
func (x *XLSXWriter) WriteHeader(cols []string) error {
vals := make([]interface{}, len(cols))
for i := range cols {
vals[i] = cols[i]
}
axis := "A" + itoa(1)
if err := x.sw.SetRow(axis, vals); err != nil {
return err
}
x.row = 2
return nil
}
func (x *XLSXWriter) WriteRow(vals []string) error {
rowVals := make([]interface{}, len(vals))
for i := range vals {
rowVals[i] = vals[i]
}
axis := "A" + itoa(x.row)
if err := x.sw.SetRow(axis, rowVals); err != nil {
return err
}
x.row++
return nil
}
func (x *XLSXWriter) Close() (string, int64, error) {
if x.sw != nil {
_ = x.sw.Flush()
}
if err := x.f.SaveAs(x.path); err != nil {
return "", 0, err
}
info, err := os.Stat(x.path)
if err != nil {
return x.path, 0, nil
}
return x.path, info.Size(), nil
}
func col(n int) string {
s := ""
for n > 0 {
n--
s = string(rune('A'+(n%26))) + s
n /= 26
}
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
b := make([]byte, 0, 10)
m := n
for m > 0 {
b = append(b, byte('0'+(m%10)))
m /= 10
}
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}

View File

@ -0,0 +1,39 @@
package logging
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"time"
)
func Init(dir string) error {
if dir == "" {
dir = "log"
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
name := fmt.Sprintf("server-%s.log", time.Now().Format("20060102"))
p := filepath.Join(dir, name)
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
mw := io.MultiWriter(os.Stdout, f)
log.SetOutput(mw)
log.SetFlags(0)
return nil
}
func JSON(level string, fields map[string]interface{}) {
m := map[string]interface{}{"level": level, "ts": time.Now().Format(time.RFC3339)}
for k, v := range fields {
m[k] = v
}
b, _ := json.Marshal(m)
log.Println(string(b))
}

View File

@ -0,0 +1,92 @@
package migrate
import (
"database/sql"
)
func Apply(db *sql.DB) error {
stmts := []string{
"CREATE TABLE IF NOT EXISTS export_templates (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, datasource VARCHAR(32) NOT NULL, main_table VARCHAR(64) NOT NULL, joins_json JSON, fields_json JSON, filters_json JSON, file_format VARCHAR(16) NOT NULL, stats_enabled TINYINT(1) NOT NULL DEFAULT 0, sheet_split_by VARCHAR(64), visibility VARCHAR(16) NOT NULL DEFAULT 'private', owner_id BIGINT UNSIGNED NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, explain_json JSON, explain_score INT, last_validated_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP)",
"CREATE TABLE IF NOT EXISTS export_jobs (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, template_id BIGINT UNSIGNED NOT NULL, status VARCHAR(16) NOT NULL, requested_by BIGINT UNSIGNED NOT NULL, permission_scope_json JSON, options_json JSON, row_estimate BIGINT, total_rows BIGINT, file_format VARCHAR(16) NOT NULL, started_at DATETIME, finished_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_template_id (template_id), INDEX idx_status (status), INDEX idx_requested_by (requested_by))",
"CREATE TABLE IF NOT EXISTS export_job_files (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, job_id BIGINT UNSIGNED NOT NULL, storage_uri VARCHAR(1024) NOT NULL, sheet_name VARCHAR(255), row_count BIGINT, size_bytes BIGINT, checksum VARCHAR(128), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_job_id (job_id))",
"CREATE TABLE IF NOT EXISTS export_audits (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, actor_id BIGINT UNSIGNED NOT NULL, action VARCHAR(64) NOT NULL, entity_type VARCHAR(64) NOT NULL, entity_id BIGINT UNSIGNED NOT NULL, detail_json JSON, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_entity (entity_type, entity_id), INDEX idx_actor (actor_id))",
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return err
}
}
comments := []string{
"ALTER TABLE export_templates COMMENT='导出模板主表'",
"ALTER TABLE export_templates MODIFY id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '主键ID'",
"ALTER TABLE export_templates MODIFY name VARCHAR(255) NOT NULL COMMENT '模板名称'",
"ALTER TABLE export_templates MODIFY datasource VARCHAR(32) NOT NULL COMMENT '数据源标识'",
"ALTER TABLE export_templates MODIFY main_table VARCHAR(64) NOT NULL COMMENT '主表名'",
"ALTER TABLE export_templates MODIFY joins_json JSON COMMENT '关联表定义'",
"ALTER TABLE export_templates MODIFY fields_json JSON COMMENT '字段选择'",
"ALTER TABLE export_templates MODIFY filters_json JSON COMMENT '过滤条件'",
"ALTER TABLE export_templates MODIFY file_format VARCHAR(16) NOT NULL COMMENT '文件格式'",
"ALTER TABLE export_templates MODIFY stats_enabled TINYINT(1) NOT NULL COMMENT '统计开关'",
"ALTER TABLE export_templates MODIFY sheet_split_by VARCHAR(64) COMMENT '多sheet拆分条件'",
"ALTER TABLE export_templates MODIFY visibility VARCHAR(16) NOT NULL COMMENT '可见性'",
"ALTER TABLE export_templates MODIFY owner_id BIGINT UNSIGNED NOT NULL COMMENT '所有者ID'",
"ALTER TABLE export_templates MODIFY enabled TINYINT(1) NOT NULL COMMENT '启用状态'",
"ALTER TABLE export_templates MODIFY explain_json JSON COMMENT 'EXPLAIN结果'",
"ALTER TABLE export_templates MODIFY explain_score INT COMMENT 'EXPLAIN评分'",
"ALTER TABLE export_templates MODIFY last_validated_at DATETIME COMMENT '最近校验时间'",
"ALTER TABLE export_templates MODIFY created_at DATETIME NOT NULL COMMENT '创建时间'",
"ALTER TABLE export_templates MODIFY updated_at DATETIME NOT NULL COMMENT '更新时间'",
"ALTER TABLE export_jobs COMMENT='导出任务表'",
"ALTER TABLE export_jobs MODIFY id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '主键ID'",
"ALTER TABLE export_jobs MODIFY template_id BIGINT UNSIGNED NOT NULL COMMENT '模板ID'",
"ALTER TABLE export_jobs MODIFY status VARCHAR(16) NOT NULL COMMENT '任务状态'",
"ALTER TABLE export_jobs MODIFY requested_by BIGINT UNSIGNED NOT NULL COMMENT '请求人ID'",
"ALTER TABLE export_jobs MODIFY permission_scope_json JSON COMMENT '权限范围'",
"ALTER TABLE export_jobs MODIFY options_json JSON COMMENT '执行选项'",
"ALTER TABLE export_jobs MODIFY row_estimate BIGINT COMMENT '行数估算'",
"ALTER TABLE export_jobs MODIFY total_rows BIGINT COMMENT '实际行数'",
"ALTER TABLE export_jobs MODIFY file_format VARCHAR(16) NOT NULL COMMENT '文件格式'",
"ALTER TABLE export_jobs MODIFY started_at DATETIME COMMENT '开始时间'",
"ALTER TABLE export_jobs MODIFY finished_at DATETIME COMMENT '完成时间'",
"ALTER TABLE export_jobs MODIFY created_at DATETIME NOT NULL COMMENT '创建时间'",
"ALTER TABLE export_jobs MODIFY updated_at DATETIME NOT NULL COMMENT '更新时间'",
"ALTER TABLE export_job_files COMMENT='导出文件记录'",
"ALTER TABLE export_job_files MODIFY id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '主键ID'",
"ALTER TABLE export_job_files MODIFY job_id BIGINT UNSIGNED NOT NULL COMMENT '任务ID'",
"ALTER TABLE export_job_files MODIFY storage_uri VARCHAR(1024) NOT NULL COMMENT '存储地址'",
"ALTER TABLE export_job_files MODIFY sheet_name VARCHAR(255) COMMENT 'sheet名称'",
"ALTER TABLE export_job_files MODIFY row_count BIGINT COMMENT '行数'",
"ALTER TABLE export_job_files MODIFY size_bytes BIGINT COMMENT '文件大小'",
"ALTER TABLE export_job_files MODIFY checksum VARCHAR(128) COMMENT '校验值'",
"ALTER TABLE export_job_files MODIFY created_at DATETIME NOT NULL COMMENT '创建时间'",
"ALTER TABLE export_audits COMMENT='审计与变更记录'",
"ALTER TABLE export_audits MODIFY id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '主键ID'",
"ALTER TABLE export_audits MODIFY actor_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID'",
"ALTER TABLE export_audits MODIFY action VARCHAR(64) NOT NULL COMMENT '动作'",
"ALTER TABLE export_audits MODIFY entity_type VARCHAR(64) NOT NULL COMMENT '对象类型'",
"ALTER TABLE export_audits MODIFY entity_id BIGINT UNSIGNED NOT NULL COMMENT '对象ID'",
"ALTER TABLE export_audits MODIFY detail_json JSON COMMENT '详情'",
"ALTER TABLE export_audits MODIFY created_at DATETIME NOT NULL COMMENT '创建时间'",
}
for _, s := range comments {
if _, err := db.Exec(s); err != nil {
return err
}
}
optional := []string{
"ALTER TABLE export_jobs ADD COLUMN explain_json JSON",
"ALTER TABLE export_jobs ADD COLUMN explain_score INT",
"ALTER TABLE export_jobs ADD COLUMN filters_json JSON",
"ALTER TABLE export_job_files ADD COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
}
for _, s := range optional {
if _, err := db.Exec(s); err != nil {
// ignore if column exists or syntax not supported
continue
}
}
return nil
}

View File

@ -0,0 +1,17 @@
package models
import "time"
type Job struct {
ID uint64
TemplateID uint64
Status string
RequestedBy uint64
PermissionScopeJSON []byte
OptionsJSON []byte
RowEstimate *int64
TotalRows *int64
FileFormat string
StartedAt *time.Time
FinishedAt *time.Time
}

View File

@ -0,0 +1,19 @@
package models
type Template struct {
ID uint64
Name string
Datasource string
MainTable string
JoinsJSON []byte
FieldsJSON []byte
FiltersJSON []byte
FileFormat string
StatsEnabled bool
SheetSplitBy *string
Visibility string
OwnerID uint64
Enabled bool
ExplainJSON []byte
ExplainScore *int
}

View File

@ -0,0 +1,256 @@
package repo
import (
"database/sql"
"encoding/json"
"server/internal/exporter"
"server/internal/logging"
"time"
)
type ExportQueryRepo struct{}
func NewExportRepo() *ExportQueryRepo { return &ExportQueryRepo{} }
func (r *ExportQueryRepo) Build(req exporter.BuildRequest, wl map[string]bool) (string, []interface{}, error) {
return exporter.BuildSQL(req, wl)
}
func (r *ExportQueryRepo) Explain(db *sql.DB, q string, args []interface{}) (int, []string, error) {
return exporter.EvaluateExplain(db, q, args)
}
func (r *ExportQueryRepo) Count(db *sql.DB, base string, args []interface{}) int64 {
return exporter.CountRows(db, base, args)
}
// Count by BuildRequest using filters-only joins and COUNT(DISTINCT main pk)
func (r *ExportQueryRepo) CountByReq(db *sql.DB, req exporter.BuildRequest, wl map[string]bool) int64 {
q, args, err := exporter.BuildCountSQL(req, wl)
if err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "build_count_sql_error", "error": err.Error()})
return 0
}
var c int64
row := db.QueryRow(q, args...)
if err := row.Scan(&c); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "count_by_req_error", "error": err.Error(), "sql": q, "args": args})
return 0
}
return c
}
func (r *ExportQueryRepo) EstimateFast(db *sql.DB, ds, main string, filters map[string]interface{}) int64 {
return exporter.CountRowsFast(db, ds, main, filters)
}
func (r *ExportQueryRepo) EstimateFastChunked(db *sql.DB, ds, main string, filters map[string]interface{}) int64 {
return exporter.CountRowsFastChunked(db, ds, main, filters)
}
func (r *ExportQueryRepo) NewCursor(datasource, main string) *exporter.CursorSQL {
return exporter.NewCursorSQL(datasource, main)
}
type RowWriterFactory func() (exporter.RowWriter, error)
type RowTransform func([]string) []string
type RollCallback func(path string, size int64, partRows int64) error
type ProgressCallback func(totalRows int64) error
func (r *ExportQueryRepo) StreamCursor(db *sql.DB, base string, args []interface{}, cur *exporter.CursorSQL, batch int, cols []string, newWriter RowWriterFactory, transform RowTransform, maxRowsPerFile int64, onRoll RollCallback, onProgress ProgressCallback) (int64, []string, error) {
return exporter.StreamWithCursor(db, base, args, cur, batch, cols, func() (exporter.RowWriter, error) { return newWriter() }, func(vals []string) []string { return transform(vals) }, maxRowsPerFile, func(p string, sz int64, rows int64) error { return onRoll(p, sz, rows) }, func(total int64) error { return onProgress(total) })
}
func (r *ExportQueryRepo) ZipAndRecord(meta *sql.DB, jobID uint64, files []string, total int64) {
if len(files) == 0 {
return
}
zipPath, zipSize := exporter.ZipFiles(jobID, files)
meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", jobID, zipPath, total, zipSize, time.Now(), time.Now())
}
// Metadata and job helpers
func (r *ExportQueryRepo) GetTemplateMeta(meta *sql.DB, tplID uint64) (string, string, []string, error) {
var ds string
var main string
var fieldsJSON []byte
row := meta.QueryRow("SELECT datasource, main_table, fields_json FROM export_templates WHERE id= ?", tplID)
if err := row.Scan(&ds, &main, &fieldsJSON); err != nil {
return "", "", nil, err
}
var fs []string
_ = json.Unmarshal(fieldsJSON, &fs)
return ds, main, fs, nil
}
func (r *ExportQueryRepo) GetJobFilters(meta *sql.DB, jobID uint64) (uint64, []byte, error) {
var tplID uint64
var filtersJSON []byte
row := meta.QueryRow("SELECT template_id, filters_json FROM export_jobs WHERE id= ?", jobID)
if err := row.Scan(&tplID, &filtersJSON); err != nil {
return 0, nil, err
}
return tplID, filtersJSON, nil
}
func (r *ExportQueryRepo) InsertJob(meta *sql.DB, tplID, requestedBy, owner uint64, permission, filters, options map[string]interface{}, explain map[string]interface{}, explainScore int, rowEstimate int64, fileFormat string) (uint64, error) {
ejSQL := "INSERT INTO export_jobs (template_id, status, requested_by, owner_id, permission_scope_json, filters_json, options_json, explain_json, explain_score, row_estimate, file_format, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
ejArgs := []interface{}{tplID, "queued", requestedBy, owner, toJSON(permission), toJSON(filters), toJSON(options), toJSON(explain), explainScore, rowEstimate, fileFormat, time.Now(), time.Now()}
res, err := meta.Exec(ejSQL, ejArgs...)
if err != nil {
return 0, err
}
id, _ := res.LastInsertId()
return uint64(id), nil
}
func (r *ExportQueryRepo) StartJob(meta *sql.DB, id uint64) {
if _, err := meta.Exec("UPDATE export_jobs SET status=?, started_at=?, updated_at=? WHERE id= ?", "running", time.Now(), time.Now(), id); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_update_error", "action": "start_job", "job_id": id, "error": err.Error()})
}
}
func (r *ExportQueryRepo) UpdateProgress(meta *sql.DB, id uint64, total int64) {
if _, err := meta.Exec("UPDATE export_jobs SET total_rows=GREATEST(COALESCE(total_rows,0), ?), updated_at=?, status=CASE WHEN status='queued' THEN 'running' ELSE status END WHERE id= ?", total, time.Now(), id); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_update_error", "action": "update_progress", "job_id": id, "error": err.Error()})
}
logging.JSON("INFO", map[string]interface{}{"event": "progress_update", "job_id": id, "total_rows": total})
}
func (r *ExportQueryRepo) MarkFailed(meta *sql.DB, id uint64) {
if _, err := meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_update_error", "action": "mark_failed", "job_id": id, "error": err.Error()})
}
}
func (r *ExportQueryRepo) MarkCompleted(meta *sql.DB, id uint64, total int64) {
if _, err := meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, row_estimate=GREATEST(COALESCE(row_estimate,0), ?), updated_at=? WHERE id= ?", "completed", time.Now(), total, total, time.Now(), id); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_update_error", "action": "mark_completed", "job_id": id, "error": err.Error()})
}
}
func (r *ExportQueryRepo) InsertJobFile(meta *sql.DB, id uint64, uri string, sheetName string, rowCount, size int64) {
if _, err := meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, sheet_name, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", id, uri, sheetName, rowCount, size, time.Now(), time.Now()); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_insert_error", "action": "insert_job_file", "job_id": id, "error": err.Error(), "path": uri})
}
}
func (r *ExportQueryRepo) UpdateRowEstimate(meta *sql.DB, id uint64, est int64) {
if _, err := meta.Exec("UPDATE export_jobs SET row_estimate=?, updated_at=? WHERE id= ?", est, time.Now(), id); err != nil {
logging.JSON("ERROR", map[string]interface{}{"event": "db_update_error", "action": "update_row_estimate", "job_id": id, "error": err.Error(), "row_estimate": est})
}
}
func toJSON(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
type JobDetail struct {
ID uint64
TemplateID uint64
Status string
RequestedBy uint64
TotalRows sql.NullInt64
FileFormat string
StartedAt sql.NullTime
FinishedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
ExplainScore sql.NullInt64
ExplainJSON sql.NullString
}
type JobFile struct {
URI sql.NullString
Sheet sql.NullString
RowCount sql.NullInt64
SizeBytes sql.NullInt64
}
type JobListItem struct {
ID uint64
TemplateID uint64
Status string
RequestedBy uint64
RowEstimate sql.NullInt64
TotalRows sql.NullInt64
FileFormat string
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
ExplainScore sql.NullInt64
ExplainJSON sql.NullString
}
func (r *ExportQueryRepo) GetJob(meta *sql.DB, id string) (JobDetail, error) {
row := meta.QueryRow("SELECT id, template_id, status, requested_by, total_rows, file_format, started_at, finished_at, created_at, updated_at, explain_score, explain_json FROM export_jobs WHERE id= ?", id)
var d JobDetail
err := row.Scan(&d.ID, &d.TemplateID, &d.Status, &d.RequestedBy, &d.TotalRows, &d.FileFormat, &d.StartedAt, &d.FinishedAt, &d.CreatedAt, &d.UpdatedAt, &d.ExplainScore, &d.ExplainJSON)
return d, err
}
func (r *ExportQueryRepo) ListJobFiles(meta *sql.DB, jobID string) ([]JobFile, error) {
rows, err := meta.Query("SELECT storage_uri, sheet_name, row_count, size_bytes FROM export_job_files WHERE job_id= ?", jobID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []JobFile{}
for rows.Next() {
var f JobFile
rows.Scan(&f.URI, &f.Sheet, &f.RowCount, &f.SizeBytes)
out = append(out, f)
}
return out, nil
}
func (r *ExportQueryRepo) GetLatestFileURI(meta *sql.DB, jobID string) (string, error) {
row := meta.QueryRow("SELECT storage_uri FROM export_job_files WHERE job_id=? ORDER BY id DESC LIMIT 1", jobID)
var uri string
err := row.Scan(&uri)
return uri, err
}
func (r *ExportQueryRepo) CountJobs(meta *sql.DB, tplID uint64, owner string) int64 {
var c int64
if tplID > 0 {
if owner != "" {
_ = meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id = ? AND owner_id = ?", tplID, owner).Scan(&c)
} else {
_ = meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id = ?", tplID).Scan(&c)
}
} else {
if owner != "" {
_ = meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE owner_id = ?", owner).Scan(&c)
} else {
_ = meta.QueryRow("SELECT COUNT(1) FROM export_jobs").Scan(&c)
}
}
return c
}
func (r *ExportQueryRepo) ListJobs(meta *sql.DB, tplID uint64, owner string, size, offset int) ([]JobListItem, error) {
var rows *sql.Rows
var err error
if tplID > 0 {
if owner != "" {
rows, err = meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score, explain_json FROM export_jobs WHERE template_id = ? AND owner_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", tplID, owner, size, offset)
} else {
rows, err = meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score, explain_json FROM export_jobs WHERE template_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", tplID, size, offset)
}
} else {
if owner != "" {
rows, err = meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score, explain_json FROM export_jobs WHERE owner_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", owner, size, offset)
} else {
rows, err = meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score, explain_json FROM export_jobs ORDER BY id DESC LIMIT ? OFFSET ?", size, offset)
}
}
if err != nil {
return nil, err
}
defer rows.Close()
items := []JobListItem{}
for rows.Next() {
var it JobListItem
if err := rows.Scan(&it.ID, &it.TemplateID, &it.Status, &it.RequestedBy, &it.RowEstimate, &it.TotalRows, &it.FileFormat, &it.CreatedAt, &it.UpdatedAt, &it.ExplainScore, &it.ExplainJSON); err == nil {
items = append(items, it)
}
}
return items, nil
}

View File

@ -0,0 +1,462 @@
package schema
func AllWhitelist() map[string]bool {
m := map[string]bool{
"order.order_number": true,
"order.key": true,
"order.creator": true,
"order.out_trade_no": true,
"order.type": true,
"order.status": true,
"order.account": true,
"order.product_id": true,
"order.reseller_id": true,
"order.plan_id": true,
"order.key_batch_id": true,
"order.code_batch_id": true,
"order.pay_type": true,
"order.pay_status": true,
"order.use_coupon": true,
"order.deliver_status": true,
"order.expire_time": true,
"order.recharge_time": true,
"order.contract_price": true,
"order.num": true,
"order.total": true,
"order.pay_amount": true,
"order.create_time": true,
"order.update_time": true,
"order.card_code": true,
"order.official_price": true,
"order.merchant_name": true,
"order.activity_name": true,
"order.goods_name": true,
"order.pay_time": true,
"order.coupon_id": true,
"order.discount_amount": true,
"order.supplier_product_name": true,
"order.is_inner": true,
"order.icon": true,
"order.cost_price": true,
"order.success_num": true,
"order.is_reset": true,
"order.is_retry": true,
"order.channel": true,
"order.is_store": true,
"order.trace_id": true,
"order.out_order_no": true,
"order.next_retry_time": true,
"order.recharge_suc_time": true,
"order.supplier_id": true,
"order.supplier_product_id": true,
"order.merchant_id": true,
"order.goods_id": true,
"order.activity_id": true,
"order.key_batch_name": true,
"order_detail.plan_title": true,
"order_detail.order_number": true,
"order_detail.reseller_name": true,
"order_detail.product_name": true,
"order_detail.show_url": true,
"order_detail.official_price": true,
"order_detail.cost_price": true,
"order_detail.create_time": true,
"order_detail.update_time": true,
"order_cash.order_no": true,
"order_cash.trade_no": true,
"order_cash.wechat_detail_id": true,
"order_cash.channel": true,
"order_cash.denomination": true,
"order_cash.account": true,
"order_cash.receive_name": true,
"order_cash.app_id": true,
"order_cash.cash_activity_id": true,
"order_cash.receive_status": true,
"order_cash.receive_time": true,
"order_cash.success_time": true,
"order_cash.cash_packet_id": true,
"order_cash.channel_order_id": true,
"order_cash.pay_fund_order_id": true,
"order_cash.cash_id": true,
"order_cash.amount": true,
"order_cash.activity_id": true,
"order_cash.goods_id": true,
"order_cash.merchant_id": true,
"order_cash.supplier_id": true,
"order_cash.user_id": true,
"order_cash.status": true,
"order_cash.expire_time": true,
"order_cash.create_time": true,
"order_cash.update_time": true,
"order_cash.version": true,
"order_cash.is_confirm": true,
"order_voucher.channel": true,
"order_voucher.channel_activity_id": true,
"order_voucher.channel_voucher_id": true,
"order_voucher.status": true,
"order_voucher.receive_mode": true,
"order_voucher.grant_time": true,
"order_voucher.usage_time": true,
"order_voucher.refund_time": true,
"order_voucher.status_modify_time": true,
"order_voucher.overdue_time": true,
"order_voucher.refund_amount": true,
"order_voucher.official_price": true,
"order_voucher.out_biz_no": true,
"order_voucher.account_no": true,
"plan.id": true,
"plan.title": true,
"plan.status": true,
"plan.begin_time": true,
"plan.end_time": true,
"key_batch.id": true,
"key_batch.batch_name": true,
"key_batch.bind_object": true,
"key_batch.quantity": true,
"key_batch.stock": true,
"key_batch.begin_time": true,
"key_batch.end_time": true,
"code_batch.id": true,
"code_batch.title": true,
"code_batch.status": true,
"code_batch.begin_time": true,
"code_batch.end_time": true,
"code_batch.quantity": true,
"code_batch.usage": true,
"code_batch.stock": true,
"voucher.channel": true,
"voucher.channel_activity_id": true,
"voucher.price": true,
"voucher.balance": true,
"voucher.used_amount": true,
"voucher.denomination": true,
"voucher_batch.channel_activity_id": true,
"voucher_batch.temp_no": true,
"voucher_batch.provider": true,
"voucher_batch.weight": true,
"merchant_key_send.merchant_id": true,
"merchant_key_send.out_biz_no": true,
"merchant_key_send.key": true,
"merchant_key_send.status": true,
"merchant_key_send.usage_time": true,
"merchant_key_send.create_time": true,
"order_digit.order_no": true,
"order_digit.card_no": true,
"order_digit.account": true,
"order_digit.goods_id": true,
"order_digit.merchant_id": true,
"order_digit.supplier_id": true,
"order_digit.activity_id": true,
"order_digit.user_id": true,
"order_digit.success_time": true,
"order_digit.supplier_product_no": true,
"order_digit.order_type": true,
"order_digit.end_time": true,
"order_digit.create_time": true,
"order_digit.update_time": true,
"order_digit.code": true,
"order_digit.sms_channel": true,
"goods_voucher_batch.channel_batch_no": true,
"goods_voucher_batch.voucher_subject_id": true,
"goods_voucher_batch.id": true,
"goods_voucher_batch.goods_voucher_id": true,
"goods_voucher_batch.supplier_id": true,
"goods_voucher_batch.temp_no": true,
"goods_voucher_batch.index": true,
"goods_voucher_batch.create_time": true,
"goods_voucher_batch.update_time": true,
"goods_voucher_subject_config.id": true,
"goods_voucher_subject_config.name": true,
"goods_voucher_subject_config.type": true,
"goods_voucher_subject_config.create_time": true,
"merchant.id": true,
"merchant.name": true,
"merchant.user_id": true,
"merchant.merchant_no": true,
"merchant.subject": true,
"merchant.third_party": true,
"merchant.status": true,
"merchant.balance": true,
"merchant.total_consumption": true,
"merchant.contact_name": true,
"merchant.contact_phone": true,
"merchant.contact_email": true,
"merchant.create_time": true,
"merchant.update_time": true,
"activity.id": true,
"activity.name": true,
"activity.user_id": true,
"activity.merchant_id": true,
"activity.user_name": true,
"activity.activity_no": true,
"activity.status": true,
"activity.key_total_num": true,
"activity.key_generate_num": true,
"activity.key_usable_num": true,
"activity.domain_url": true,
"activity.theme_login_id": true,
"activity.theme_list_id": true,
"activity.theme_verify_id": true,
"activity.settlement_type": true,
"activity.key_expire_type": true,
"activity.key_valid_day": true,
"activity.key_begin_time": true,
"activity.key_end_time": true,
"activity.key_style": true,
"activity.begin_time": true,
"activity.end_time": true,
"activity.is_retry": true,
"activity.create_time": true,
"activity.update_time": true,
"activity.discard_time": true,
"activity.delete_time": true,
"activity.auto_charge": true,
"activity.stock": true,
"activity.approval_trade_no": true,
"activity.amount": true,
"activity.channels": true,
"activity.key_begin": true,
"activity.key_end": true,
"activity.key_unit": true,
"activity.key_pay_button_text": true,
"activity.goods_pay_button_text": true,
"activity.is_open_db_transaction": true,
}
return m
}
func AllLabels() map[string]string {
return map[string]string{
"order.order_number": "订单编号",
"order.key": "KEY",
"order.creator": "创建者ID",
"order.out_trade_no": "支付流水号",
"order.type": "订单类型",
"order.status": "订单状态",
"order.account": "账号",
"order.product_id": "商品ID",
"order.reseller_id": "分销商ID",
"order.plan_id": "计划ID",
"order.key_batch_id": "KEY批次ID",
"order.code_batch_id": "兑换批次ID",
"order.pay_type": "支付方式",
"order.pay_status": "支付状态",
"order.use_coupon": "是否使用优惠券",
"order.deliver_status": "投递状态",
"order.expire_time": "过期处理时间",
"order.recharge_time": "充值时间",
"order.contract_price": "合同单价",
"order.num": "数量",
"order.total": "总金额",
"order.pay_amount": "支付金额",
"order.create_time": "创建时间",
"order.update_time": "更新时间",
"order.official_price": "官方价",
"order.merchant_name": "分销商名称",
"order.activity_name": "活动名称",
"order.goods_name": "商品名称",
"order.pay_time": "支付时间",
"order.coupon_id": "优惠券ID",
"order.discount_amount": "优惠金额",
"order.card_code": "卡密(脱敏)",
"order.supplier_product_name": "供应商产品名称",
"order.is_inner": "内部供应商订单",
"order.icon": "订单图片",
"order.cost_price": "成本价",
"order.success_num": "到账数量",
"order.is_reset": "是否重置",
"order.is_retry": "是否重试",
"order.channel": "支付渠道",
"order.is_store": "是否退还库存",
"order.trace_id": "TraceID",
"order.out_order_no": "外部订单号",
"order.next_retry_time": "下次重试时间",
"order.recharge_suc_time": "充值成功时间",
"order.supplier_id": "供应商ID",
"order.supplier_product_id": "供应商产品ID",
"order.merchant_id": "分销商ID",
"order.goods_id": "商品ID",
"order.activity_id": "活动ID",
"order.key_batch_name": "key批次名称",
"order_detail.plan_title": "计划标题",
"order_detail.order_number": "订单编号",
"order_detail.reseller_name": "分销商名称",
"order_detail.product_name": "商品名称",
"order_detail.show_url": "商品图片URL",
"order_detail.official_price": "官方价",
"order_detail.cost_price": "成本价",
"order_detail.create_time": "创建时间",
"order_detail.update_time": "更新时间",
"order_cash.order_no": "订单号",
"order_cash.trade_no": "交易号",
"order_cash.wechat_detail_id": "微信明细单号",
"order_cash.channel": "渠道",
"order_cash.denomination": "红包面额",
"order_cash.account": "领取账号",
"order_cash.receive_name": "真实姓名",
"order_cash.app_id": "转账AppID",
"order_cash.cash_activity_id": "红包批次号",
"order_cash.receive_status": "领取状态",
"order_cash.receive_time": "拆红包时间",
"order_cash.success_time": "成功时间",
"order_cash.cash_packet_id": "红包ID",
"order_cash.channel_order_id": "渠道订单号",
"order_cash.pay_fund_order_id": "资金订单号",
"order_cash.cash_id": "红包规则ID",
"order_cash.amount": "红包额度",
"order_cash.activity_id": "活动ID",
"order_cash.goods_id": "商品ID",
"order_cash.merchant_id": "分销商ID",
"order_cash.supplier_id": "供应商ID",
"order_cash.user_id": "创建者ID",
"order_cash.status": "状态",
"order_cash.expire_time": "过期时间",
"order_cash.create_time": "创建时间",
"order_cash.update_time": "更新时间",
"order_cash.version": "版本",
"order_cash.is_confirm": "是否确认",
"order_voucher.channel": "渠道",
"order_voucher.channel_activity_id": "渠道立减金批次",
"order_voucher.channel_voucher_id": "渠道立减金ID",
"order_voucher.status": "状态",
"order_voucher.receive_mode": "领取方式",
"order_voucher.grant_time": "领取时间",
"order_voucher.usage_time": "核销时间",
"order_voucher.refund_time": "退款时间",
"order_voucher.status_modify_time": "状态更新时间",
"order_voucher.overdue_time": "过期时间",
"order_voucher.refund_amount": "退款金额",
"order_voucher.official_price": "官方价",
"order_voucher.out_biz_no": "外部业务号",
"order_voucher.account_no": "账户号",
"plan.id": "计划ID",
"plan.title": "计划标题",
"plan.status": "状态",
"plan.begin_time": "开始时间",
"plan.end_time": "结束时间",
"key_batch.id": "批次ID",
"key_batch.batch_name": "批次名称",
"key_batch.bind_object": "绑定对象",
"key_batch.quantity": "发放数量",
"key_batch.stock": "剩余库存",
"key_batch.begin_time": "开始时间",
"key_batch.end_time": "结束时间",
"code_batch.id": "兑换批次ID",
"code_batch.title": "标题",
"code_batch.status": "状态",
"code_batch.begin_time": "开始时间",
"code_batch.end_time": "结束时间",
"code_batch.quantity": "数量",
"code_batch.usage": "使用数",
"code_batch.stock": "库存",
"voucher.channel": "渠道",
"voucher.channel_activity_id": "渠道批次号",
"voucher.price": "合同单价",
"voucher.balance": "剩余额度",
"voucher.used_amount": "已用额度",
"voucher.denomination": "面额",
"voucher_batch.channel_activity_id": "渠道批次号",
"voucher_batch.temp_no": "模板编号",
"voucher_batch.provider": "服务商",
"voucher_batch.weight": "权重",
"merchant_key_send.merchant_id": "商户ID",
"merchant_key_send.out_biz_no": "商户业务号",
"merchant_key_send.key": "券码",
"merchant_key_send.status": "状态",
"merchant_key_send.usage_time": "核销时间",
"merchant_key_send.create_time": "创建时间",
"order_digit.order_no": "订单号",
"order_digit.card_no": "卡号",
"order_digit.account": "充值账号",
"order_digit.goods_id": "商品ID",
"order_digit.merchant_id": "分销商ID",
"order_digit.supplier_id": "供应商ID",
"order_digit.activity_id": "活动ID",
"order_digit.user_id": "创建者ID",
"order_digit.success_time": "到账时间",
"order_digit.supplier_product_no": "供应商产品编码",
"order_digit.order_type": "订单类型",
"order_digit.end_time": "卡密有效期",
"order_digit.create_time": "创建时间",
"order_digit.update_time": "更新时间",
"order_digit.code": "验证码",
"order_digit.sms_channel": "短信渠道",
"goods_voucher_batch.channel_batch_no": "渠道批次号",
"goods_voucher_batch.voucher_subject_id": "主体配置ID",
"goods_voucher_batch.id": "ID",
"goods_voucher_batch.goods_voucher_id": "立减金ID",
"goods_voucher_batch.supplier_id": "供应商ID",
"goods_voucher_batch.temp_no": "模板编号",
"goods_voucher_batch.index": "权重",
"goods_voucher_batch.create_time": "创建时间",
"goods_voucher_batch.update_time": "更新时间",
"goods_voucher_subject_config.id": "主体配置ID",
"goods_voucher_subject_config.name": "主体名称",
"goods_voucher_subject_config.type": "主体类型",
"goods_voucher_subject_config.create_time": "创建时间",
"merchant.id": "客户ID",
"merchant.name": "客户名称",
"merchant.user_id": "用户中心ID",
"merchant.merchant_no": "商户编码",
"merchant.subject": "客户主体",
"merchant.third_party": "来源类型",
"merchant.status": "状态",
"merchant.balance": "客户余额",
"merchant.total_consumption": "累计消费",
"merchant.contact_name": "联系人名称",
"merchant.contact_phone": "联系人电话",
"merchant.contact_email": "联系人Email",
"merchant.create_time": "创建时间",
"merchant.update_time": "编辑时间",
"activity.id": "活动ID",
"activity.name": "活动名称",
"activity.user_id": "创建者ID",
"activity.merchant_id": "客户ID",
"activity.user_name": "创建者名称",
"activity.activity_no": "活动编号",
"activity.status": "状态",
"activity.key_total_num": "Key码总量",
"activity.key_generate_num": "Key码已生成数量",
"activity.key_usable_num": "Key可使用次数",
"activity.domain_url": "域名",
"activity.theme_login_id": "登录模版ID",
"activity.theme_list_id": "列表模版ID",
"activity.theme_verify_id": "验证模版ID",
"activity.settlement_type": "结算方式",
"activity.key_expire_type": "Key有效期类型",
"activity.key_valid_day": "有效天数",
"activity.key_begin_time": "Key有效开始时间",
"activity.key_end_time": "Key有效结束时间",
"activity.key_style": "Key样式",
"activity.begin_time": "开始时间",
"activity.end_time": "结束时间",
"activity.is_retry": "是否自动重试",
"activity.create_time": "创建时间",
"activity.update_time": "修改时间",
"activity.discard_time": "作废时间",
"activity.delete_time": "删除时间",
"activity.auto_charge": "是否充值到账",
"activity.stock": "已使用库存",
"activity.approval_trade_no": "审批交易号",
"activity.amount": "支付金额",
"activity.channels": "支付渠道",
"activity.key_begin": "开始月份",
"activity.key_end": "截止月份",
"activity.key_unit": "时间单位",
"activity.key_pay_button_text": "Key支付按钮文本",
"activity.goods_pay_button_text": "商品支付按钮文本",
"activity.is_open_db_transaction": "是否开启事务",
// removed bank_tag: not present in current YMT activity schema
}
}
func RecommendedDefaultFields(ds string) []string {
if ds == "ymt" {
return []string{
"order.order_number", "order.creator", "order.out_trade_no", "order.type", "order.status", "order.contract_price", "order.num", "order.pay_amount", "order.create_time",
}
}
return []string{
"order.order_number", "order.creator", "order.out_trade_no", "order.type", "order.status", "order.contract_price", "order.num", "order.total", "order.pay_amount", "order.create_time",
}
}

View File

@ -0,0 +1,60 @@
package schema
type marketingSchema struct{}
func (marketingSchema) TableName(t string) string { return t }
func (marketingSchema) MapField(t, f string) (string, bool) { return f, true }
func (marketingSchema) BuildJoins(need map[string]bool, main string) []string {
out := []string{}
if need["order_detail"] {
out = append(out, " LEFT JOIN `order_detail` ON `order_detail`.order_number = `order`.order_number")
}
if need["order_cash"] {
out = append(out, " LEFT JOIN `order_cash` ON `order_cash`.order_number = `order`.order_number")
}
if need["order_voucher"] {
out = append(out, " LEFT JOIN `order_voucher` ON `order_voucher`.order_number = `order`.order_number")
}
if need["plan"] || need["key_batch"] {
out = append(out, " LEFT JOIN `plan` ON `plan`.id = `order`.plan_id")
}
if need["key_batch"] {
out = append(out, " LEFT JOIN `key_batch` ON `key_batch`.plan_id = `plan`.id")
}
if need["code_batch"] {
out = append(out, " LEFT JOIN `code_batch` ON `code_batch`.key_batch_id = `key_batch`.id")
}
if need["voucher"] {
out = append(out, " LEFT JOIN `voucher` ON `voucher`.channel_activity_id = `order_voucher`.channel_activity_id")
}
if need["voucher_batch"] {
out = append(out, " LEFT JOIN `voucher_batch` ON `voucher_batch`.voucher_id = `voucher`.id")
}
if need["merchant_key_send"] {
out = append(out, " LEFT JOIN `merchant_key_send` ON `order`.`key` = `merchant_key_send`.key")
}
return out
}
func (marketingSchema) FilterColumn(key string) (string, string, bool) {
switch key {
case "creator_in": return "order", "creator", true
case "create_time_between": return "order", "create_time", true
case "type_eq": return "order", "type", true
case "out_trade_no_eq": return "order", "out_trade_no", true
case "account_eq": return "order", "account", true
case "plan_id_eq": return "order", "plan_id", true
case "key_batch_id_eq": return "order", "key_batch_id", true
case "product_id_eq": return "order", "product_id", true
case "reseller_id_eq": return "order", "reseller_id", true
case "code_batch_id_eq": return "order", "code_batch_id", true
case "order_cash_cash_activity_id_eq": return "order_cash", "cash_activity_id", true
case "order_voucher_channel_activity_id_eq": return "order_voucher", "channel_activity_id", true
case "voucher_batch_channel_activity_id_eq": return "voucher_batch", "channel_activity_id", true
case "merchant_out_biz_no_eq": return "merchant_key_send", "out_biz_no", true
default:
return "", "", false
}
}

View File

@ -0,0 +1,15 @@
package schema
type Schema interface {
TableName(string) string
MapField(string, string) (string, bool)
BuildJoins(map[string]bool, string) []string
FilterColumn(string) (string, string, bool)
}
func Get(datasource string, main string) Schema {
if datasource == "ymt" || main == "order_info" {
return ymtSchema{}
}
return marketingSchema{}
}

View File

@ -0,0 +1,83 @@
package schema
type ymtSchema struct{}
func (ymtSchema) TableName(t string) string {
if t == "order" {
return "order_info"
}
return t
}
func (s ymtSchema) MapField(t, f string) (string, bool) {
if t == "order" {
switch f {
case "order_number": return "order_no", true
case "key": return "key_code", true
case "creator": return "user_id", true
case "out_trade_no": return "out_order_no", true
case "plan_id": return "activity_id", true
case "reseller_id": return "merchant_id", true
case "product_id": return "goods_id", true
case "pay_amount": return "pay_price", true
case "key_batch_id": return "key_batch_name", true
default:
return f, true
}
}
if t == "order_voucher" {
switch f {
case "channel_activity_id": return "channel_batch_no", true
case "overdue_time": return "expire_time", true
case "account_no": return "account", true
default:
return f, true
}
}
return f, true
}
func (s ymtSchema) BuildJoins(need map[string]bool, main string) []string {
out := []string{}
if need["order_cash"] {
out = append(out, " LEFT JOIN `order_cash` ON `order_cash`.order_no = `order_info`.order_no")
}
if need["order_voucher"] {
out = append(out, " LEFT JOIN `order_voucher` ON `order_voucher`.order_no = `order_info`.order_no")
}
if need["order_digit"] {
out = append(out, " LEFT JOIN `order_digit` ON `order_digit`.order_no = `order_info`.order_no")
}
if need["goods_voucher_batch"] {
out = append(out, " LEFT JOIN `goods_voucher_batch` ON `goods_voucher_batch`.channel_batch_no = `order_voucher`.channel_batch_no")
}
if need["goods_voucher_subject_config"] {
out = append(out, " LEFT JOIN `goods_voucher_subject_config` ON `goods_voucher_subject_config`.id = `goods_voucher_batch`.voucher_subject_id")
}
if need["merchant"] {
out = append(out, " LEFT JOIN `merchant` ON `merchant`.id = `order_info`.merchant_id")
}
if need["activity"] {
out = append(out, " LEFT JOIN `activity` ON `activity`.id = `order_info`.activity_id")
}
return out
}
func (s ymtSchema) FilterColumn(key string) (string, string, bool) {
switch key {
case "creator_in": return "order", "user_id", true
case "create_time_between": return "order", "create_time", true
case "type_eq": return "order", "type", true
case "out_trade_no_eq": return "order", "out_order_no", true
case "account_eq": return "order", "account", true
case "plan_id_eq": return "order", "activity_id", true
case "key_batch_id_eq": return "order", "key_batch_name", true
case "product_id_eq": return "order", "goods_id", true
case "reseller_id_eq": return "order", "merchant_id", true
case "code_batch_id_eq": return "order", "supplier_product_id", true
case "order_cash_cash_activity_id_eq": return "order_cash", "activity_id", true
case "order_voucher_channel_activity_id_eq": return "order_voucher", "channel_batch_no", true
default:
return "", "", false
}
}

View File

@ -0,0 +1,48 @@
package ymtcrypto
import (
"crypto/cipher"
"encoding/base64"
"errors"
sm4 "github.com/tjfoc/gmsm/sm4"
)
func SM4Decrypt(encrypted, encryptKey string) (string, error) {
if encrypted == "" {
return "", nil
}
d, err := base64.StdEncoding.DecodeString(encryptKey)
if err != nil {
return "", err
}
if len(d) != 16 {
return "", errors.New("invalid sm4 key length")
}
cipherBlock, err := sm4.NewCipher(d)
if err != nil {
return "", err
}
blockSize := cipherBlock.BlockSize()
iv := make([]byte, blockSize)
for i := 0; i < blockSize; i++ { iv[i] = 0 }
cipherText, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
if len(cipherText)%blockSize != 0 {
return "", errors.New("invalid sm4 ciphertext size")
}
plainText := make([]byte, len(cipherText))
blockMode := cipher.NewCBCDecrypter(cipherBlock, iv)
blockMode.CryptBlocks(plainText, cipherText)
if len(plainText) == 0 {
return "", nil
}
padding := int(plainText[len(plainText)-1])
if padding <= 0 || padding > len(plainText) {
return "", errors.New("invalid padding")
}
buff := plainText[:len(plainText)-padding]
return string(buff), nil
}

BIN
server/server Executable file

Binary file not shown.

312
web/index.html Normal file
View File

@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MarketingSystemDataTool</title>
<link rel="stylesheet" href="./vendor/element-plus.min.css">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="app">
<el-container>
<el-header height="56px">
<el-row align="middle" justify="space-between">
<el-col :span="24">
<div class="title">导出工具</div>
</el-col>
</el-row>
</el-header>
<el-main>
<el-row :gutter="16">
<el-col :span="24">
<el-card>
<template #header>
<div style="display:flex;align-items:center;gap:8px">
<span>导出模版</span>
<el-button type="primary" size="small" @click="createVisible=true">新增模板</el-button>
</div>
</template>
<el-table :data="templates" size="small" stripe>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="名称"></el-table-column>
<el-table-column label="数据源" width="120">
<template #default="scope">{{ dsLabel(scope.row.datasource) }}</template>
</el-table-column>
<el-table-column prop="file_format" label="格式" width="100"></el-table-column>
<el-table-column prop="field_count" label="字段数" width="100"></el-table-column>
<el-table-column label="操作" width="260">
<template #default="scope">
<el-button size="small" type="primary" @click="openExport(scope.row)">执行</el-button>
<el-button size="small" @click="openJobs(scope.row)">任务</el-button>
<el-button
v-if="(!hasUserId) || (hasUserId && Number(scope.row.owner_id)===currentUserId)"
size="small"
@click="openEdit(scope.row)">编辑</el-button>
<el-button
v-if="(!hasUserId) || (Number(scope.row.owner_id)!==0 && Number(scope.row.owner_id)===currentUserId)"
size="small"
type="danger"
@click="removeTemplate(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="24">
<el-dialog v-model="jobsVisible" :title="'导出任务(模板 '+ (jobsTplId||'') +''" width="1000px">
<el-table :data="jobs" size="small" stripe row-key="id">
<el-table-column prop="id" label="ID"></el-table-column>
<el-table-column label="校验状态">
<template #default="scope">{{ scope.row.eval_status || '评估中' }}</template>
</el-table-column>
<el-table-column label="进度">
<template #default="scope">{{ jobPercent(scope.row) }}</template>
</el-table-column>
<el-table-column label="行数">
<template #default="scope">
{{ Number(scope.row.row_estimate||0) > 0 ? Number(scope.row.row_estimate||0) : '评估中' }}
</template>
</el-table-column>
<el-table-column prop="total_rows" label="已写行数"></el-table-column>
<el-table-column prop="file_format" label="格式"></el-table-column>
<el-table-column label="创建时间">
<template #default="scope">{{ fmtDT(new Date(scope.row.created_at)) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="openSQL(scope.row.id)">分析</el-button>
<el-button v-if="scope.row.status==='completed' && Number(scope.row.total_rows)>0" size="small" type="success" @click="download(scope.row.id)">下载</el-button>
<el-button v-else-if="scope.row.status==='completed'" size="small" disabled>无数据</el-button>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:8px">
<el-pagination background layout="prev, pager, next, total" :total="jobsTotal" :page-size="jobsPageSize" v-model:currentPage="jobsPage" @current-change="loadJobs" />
</div>
<template #footer>
<el-button @click="closeJobs">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="sqlVisible" title="生成SQL" width="800px">
<div style="max-height:50vh;overflow:auto"><pre style="white-space:pre-wrap">{{ sqlText }}</pre></div>
<template #footer>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%">
<div style="flex:1; min-width:0; text-align:left; color:#606266">
<el-text v-if="sqlExplainDesc" type="info" truncated>{{ sqlExplainDesc }}</el-text>
<el-text v-else type="info">评估中或暂无分析结果</el-text>
</div>
<div style="display:flex; gap:8px">
<el-button @click="sqlVisible=false">关闭</el-button>
</div>
</div>
</template>
</el-dialog>
</el-col>
</el-row>
</el-main>
</el-container>
<el-dialog v-model="createVisible" title="新增模板" :width="createWidth">
<el-form ref="createFormRef" :model="form" :rules="createRules" label-width="110px" status-icon>
<el-form-item label="模板名称" required show-message prop="name"><el-input v-model="form.name" placeholder="模板名称" /></el-form-item>
<el-form-item label="数据源" required show-message>
<el-select v-model="form.datasource" placeholder="选择" :teleported="false">
<el-option v-for="opt in datasourceOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="导出场景" required show-message>
<el-select v-model="form.main_table" placeholder="订单数据">
<el-option v-for="opt in sceneOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="订单类型" required show-message prop="orderType">
<el-radio-group v-model="form.orderType">
<el-radio v-for="opt in orderTypeOptionsFor(form.datasource)" :key="opt.value" :label="opt.value">{{ opt.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="字段选择" required show-message prop="fieldsSel">
<div ref="createCascaderRoot">
<el-cascader
ref="fieldsCascader"
v-model="form.fieldsSel"
:key="form.datasource + '-' + String(form.orderType)"
:options="fieldOptionsDynamic"
:props="{ multiple: true, checkStrictly: false, expandTrigger: 'hover', checkOnClickNode: true, checkOnClickLeaf: true }"
:teleported="false"
collapse-tags
collapse-tags-tooltip
placeholder="按场景逐级选择,可多选"
@visible-change="onCascaderVisible('create', $event)"
@change="onFieldsSelChange('create')"
/>
</div>
</el-form-item>
<el-row :gutter="8">
<el-col :span="12">
<el-form-item label="输出格式" required show-message prop="file_format">
<el-select v-model="form.file_format" :teleported="false" placeholder="请选择" style="width:160px">
<el-option v-for="opt in formatOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可见性" required show-message prop="visibility">
<el-select v-model="form.visibility" clearable :teleported="false" style="width:160px" placeholder="请选择">
<el-option v-for="opt in visibilityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="resizeDialog('create', -100)">缩小</el-button>
<el-button @click="resizeDialog('create', 100)">放大</el-button>
<el-button @click="createVisible=false">取消</el-button>
<el-button type="primary" @click="createTemplate">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑模板" :width="editWidth">
<el-form ref="editFormRef" :model="edit" :rules="editRules" label-width="110px">
<el-form-item label="模板名称" required show-message prop="name"><el-input v-model="edit.name" /></el-input></el-form-item>
<el-form-item label="数据源">
<el-select v-model="edit.datasource" placeholder="选择" :teleported="false" style="width:160px">
<el-option v-for="opt in datasourceOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="导出场景">
<el-select v-model="edit.main_table" placeholder="订单数据" style="width:160px">
<el-option v-for="opt in editSceneOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="订单类型" required show-message prop="orderType">
<el-radio-group v-model="edit.orderType">
<el-radio v-for="opt in orderTypeOptionsFor(edit.datasource)" :key="opt.value" :label="opt.value">{{ opt.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="字段选择" required show-message prop="fieldsSel">
<div ref="editCascaderRoot">
<el-cascader
ref="editFieldsCascader"
v-model="edit.fieldsSel"
:key="edit.datasource + '-' + String(edit.orderType)"
:options="editFieldOptionsDynamic"
:props="{ multiple: true, checkStrictly: false, expandTrigger: 'hover', checkOnClickNode: true, checkOnClickLeaf: true }"
:teleported="false"
collapse-tags
collapse-tags-tooltip
placeholder="按场景逐级选择,可多选"
@visible-change="onCascaderVisible('edit', $event)"
@change="onFieldsSelChange('edit')"
/>
</div>
</el-form-item>
<el-row :gutter="8">
<el-col :span="12">
<el-form-item label="输出格式">
<el-select v-model="edit.file_format" :teleported="false" placeholder="请选择" style="width:160px">
<el-option v-for="opt in formatOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可见性">
<el-select v-model="edit.visibility" clearable :disabled="hasUserId" :teleported="false" style="width:160px" placeholder="请选择">
<el-option v-for="opt in visibilityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="resizeDialog('edit', -100)">缩小</el-button>
<el-button @click="resizeDialog('edit', 100)">放大</el-button>
<el-button @click="editVisible=false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="exportVisible" :title="exportTitle" width="1100px">
<el-form ref="exportFormRef" :model="exportForm" :rules="exportRules" label-width="110px" status-icon>
<div class="section-title">筛选条件</div>
<el-row :gutter="8">
<el-col :span="24">
<el-form-item label="时间范围" required show-message prop="dateRange">
<el-date-picker v-model="exportForm.dateRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" v-if="isOrder && exportForm.datasource==='ymt'">
<el-col :span="8">
<el-form-item label="创建者" prop="ymtCreatorId">
<el-select v-model.number="exportForm.ymtCreatorId" :disabled="hasUserId" clearable filterable :teleported="false" placeholder="请选择创建者" style="width:100%">
<el-option v-for="opt in ymtCreatorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户" prop="ymtMerchantId">
<el-select v-model.number="exportForm.ymtMerchantId" :disabled="!exportForm.ymtCreatorId" clearable filterable :teleported="false" placeholder="请选择客户" style="width:100%">
<el-option v-for="opt in ymtMerchantOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="活动" prop="ymtActivityId">
<el-select v-model.number="exportForm.ymtActivityId" :disabled="!exportForm.ymtMerchantId" clearable filterable :teleported="false" placeholder="请选择活动" style="width:100%">
<el-option v-for="opt in ymtActivityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" v-if="isOrder && exportForm.datasource==='marketing'">
<el-col :span="12">
<el-form-item label="创建者" prop="creator">
<el-select v-model="exportForm.creatorIds" multiple filterable :disabled="hasUserId" :teleported="false" placeholder="请选择创建者" style="width:100%">
<el-option v-for="opt in creatorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分销商" prop="resellerId">
<el-select v-model.number="exportForm.resellerId" filterable :teleported="false" placeholder="请选择分销商" style="width:100%" :disabled="!hasCreators">
<el-option v-for="opt in resellerOptions" :key="opt.value" :label="opt.label||String(opt.value)" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" v-if="isOrder && exportForm.datasource==='marketing'">
<el-col :span="12">
<el-form-item label="计划" prop="planId">
<el-select v-model="exportForm.planId" :disabled="!hasReseller" :teleported="false" filterable placeholder="选择计划">
<el-option v-for="opt in planOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" v-if="isOrder">
<el-col :span="12" v-if="exportType===2">
<el-form-item label="立减金批次号" prop="voucherChannelActivityId"><el-input v-model="exportForm.voucherChannelActivityId" placeholder="请输入立减金批次号" /></el-form-item>
</el-col>
</el-row>
<!-- 输出格式按模板设置,不在此显示 -->
</el-form>
<template #footer>
<el-button @click="exportVisible=false">取消</el-button>
<el-button type="primary" :loading="exportSubmitting" :disabled="exportSubmitting" @click="submitExport">{{ exportSubmitting ? '估算中...' : '执行并分析' }}</el-button>
</template>
</el-dialog>
</div>
<script src="./vendor/vue.global.prod.js"></script>
<script src="./vendor/element-plus.full.min.js"></script>
<script src="./main.js"></script>
</body>
</html>

1396
web/main.js Normal file

File diff suppressed because it is too large Load Diff

2
web/styles.css Normal file
View File

@ -0,0 +1,2 @@
body{font-family:system-ui,Arial}
.section-title{font-weight:600;margin:8px 0}

72
web/vendor/element-plus.full.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
web/vendor/element-plus.min.css vendored Normal file

File diff suppressed because one or more lines are too long

12
web/vendor/vue.global.prod.js vendored Normal file

File diff suppressed because one or more lines are too long