545 lines
16 KiB
Go
545 lines
16 KiB
Go
// Package api 提供HTTP API处理器
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"server/internal/exporter"
|
|
"server/internal/schema"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ==================== 模板API处理器 ====================
|
|
|
|
// TemplatesAPI 模板管理API
|
|
type TemplatesAPI struct {
|
|
metaDB *sql.DB // 元数据库(存储模板和任务)
|
|
marketingDB *sql.DB // 营销系统数据库
|
|
}
|
|
|
|
// TemplatesHandler 创建模板API处理器
|
|
func TemplatesHandler(metaDB, marketingDB *sql.DB) http.Handler {
|
|
api := &TemplatesAPI{metaDB: metaDB, marketingDB: marketingDB}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/templates")
|
|
|
|
// POST /api/templates - 创建模板
|
|
if r.Method == http.MethodPost && path == "" {
|
|
api.createTemplate(w, r)
|
|
return
|
|
}
|
|
|
|
// GET /api/templates - 获取模板列表
|
|
if r.Method == http.MethodGet && path == "" {
|
|
api.listTemplates(w, r)
|
|
return
|
|
}
|
|
|
|
// 带ID的路径处理
|
|
if strings.HasPrefix(path, "/") {
|
|
templateID := strings.TrimPrefix(path, "/")
|
|
|
|
// GET /api/templates/:id - 获取单个模板
|
|
if r.Method == http.MethodGet {
|
|
api.getTemplate(w, r, templateID)
|
|
return
|
|
}
|
|
|
|
// PATCH /api/templates/:id - 更新模板
|
|
if r.Method == http.MethodPatch {
|
|
api.patchTemplate(w, r, templateID)
|
|
return
|
|
}
|
|
|
|
// DELETE /api/templates/:id - 删除模板
|
|
if r.Method == http.MethodDelete {
|
|
api.deleteTemplate(w, r, templateID)
|
|
return
|
|
}
|
|
|
|
// POST /api/templates/:id/validate - 验证模板
|
|
if r.Method == http.MethodPost && strings.HasSuffix(path, "/validate") {
|
|
templateID = strings.TrimSuffix(templateID, "/validate")
|
|
api.validateTemplate(w, r, templateID)
|
|
return
|
|
}
|
|
}
|
|
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
})
|
|
}
|
|
|
|
// ==================== 请求/响应结构 ====================
|
|
|
|
// TemplatePayload 模板创建/更新请求体
|
|
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"`
|
|
}
|
|
|
|
// ==================== API方法 ====================
|
|
|
|
// createTemplate 创建新模板
|
|
func (api *TemplatesAPI) createTemplate(w http.ResponseWriter, r *http.Request) {
|
|
// 读取并解析请求体
|
|
body, 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
|
|
}
|
|
|
|
var payload TemplatePayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
log.Printf("trace_id=%s error parsing JSON: %v", TraceIDFrom(r), err)
|
|
fail(w, r, http.StatusBadRequest, "invalid JSON format")
|
|
return
|
|
}
|
|
|
|
r = WithPayload(r, payload)
|
|
|
|
// 介URL参数获取用户ID
|
|
if userIDStr := r.URL.Query().Get("userId"); userIDStr != "" {
|
|
var userID uint64
|
|
if _, scanErr := fmt.Sscan(userIDStr, &userID); scanErr == nil && userID > 0 {
|
|
payload.OwnerID = userID
|
|
}
|
|
}
|
|
|
|
// 插入数据库
|
|
now := time.Now()
|
|
insertSQL := `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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
|
|
args := []interface{}{
|
|
payload.Name,
|
|
payload.Datasource,
|
|
payload.MainTable,
|
|
toJSON(payload.Fields),
|
|
toJSON(payload.Filters),
|
|
payload.FileFormat,
|
|
payload.Visibility,
|
|
payload.OwnerID,
|
|
1, // enabled
|
|
0, // stats_enabled
|
|
now, // last_validated_at
|
|
now, // created_at
|
|
now, // updated_at
|
|
}
|
|
|
|
log.Printf("trace_id=%s sql=%s args=%v", TraceIDFrom(r), insertSQL, args)
|
|
|
|
if _, err := api.metaDB.Exec(insertSQL, args...); err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, r, http.StatusCreated, 0, "ok", nil)
|
|
}
|
|
|
|
// listTemplates 获取模板列表
|
|
func (api *TemplatesAPI) listTemplates(w http.ResponseWriter, r *http.Request) {
|
|
userIDStr := r.URL.Query().Get("userId")
|
|
|
|
// 构建查询SQL
|
|
querySQL := `SELECT id, name, datasource, main_table, file_format, visibility,
|
|
owner_id, enabled, last_validated_at, created_at, updated_at, fields_json,
|
|
(SELECT COUNT(1) FROM export_jobs ej WHERE ej.template_id = export_templates.id) AS exec_count
|
|
FROM export_templates`
|
|
|
|
var args []interface{}
|
|
var conditions []string
|
|
|
|
if userIDStr != "" {
|
|
conditions = append(conditions, "owner_id IN (0, ?)")
|
|
args = append(args, userIDStr)
|
|
}
|
|
conditions = append(conditions, "enabled = 1")
|
|
|
|
if len(conditions) > 0 {
|
|
querySQL += " WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
querySQL += " ORDER BY datasource ASC, id DESC LIMIT 200"
|
|
|
|
rows, err := api.metaDB.Query(querySQL, args...)
|
|
if err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
whitelist := Whitelist()
|
|
templates := []map[string]interface{}{}
|
|
|
|
for rows.Next() {
|
|
var (
|
|
id uint64
|
|
name string
|
|
datasource string
|
|
mainTable string
|
|
fileFormat string
|
|
visibility string
|
|
ownerID uint64
|
|
enabled int
|
|
lastValidatedAt sql.NullTime
|
|
createdAt time.Time
|
|
updatedAt time.Time
|
|
fieldsRaw []byte
|
|
execCount int64
|
|
)
|
|
|
|
if err := rows.Scan(&id, &name, &datasource, &mainTable, &fileFormat, &visibility,
|
|
&ownerID, &enabled, &lastValidatedAt, &createdAt, &updatedAt, &fieldsRaw, &execCount); err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// 解析字段并计算有效字段数
|
|
var fields []string
|
|
_ = json.Unmarshal(fieldsRaw, &fields)
|
|
fieldCount := countValidFields(datasource, mainTable, fields, whitelist)
|
|
|
|
templates = append(templates, 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,
|
|
})
|
|
}
|
|
|
|
ok(w, r, templates)
|
|
}
|
|
|
|
// countValidFields 计算有效字段数(去重)
|
|
func countValidFields(datasource, mainTable string, fields []string, whitelist map[string]bool) int64 {
|
|
seen := map[string]struct{}{}
|
|
|
|
for _, field := range fields {
|
|
// YMT系统的order_info映射为order
|
|
if datasource == "ymt" && strings.HasPrefix(field, "order_info.") {
|
|
field = strings.Replace(field, "order_info.", "order.", 1)
|
|
}
|
|
|
|
// 检查白名单
|
|
if !whitelist[field] {
|
|
continue
|
|
}
|
|
|
|
// YMT系统客户名称去重
|
|
if datasource == "ymt" && field == "order.merchant_name" {
|
|
if _, exists := seen["merchant.name"]; exists {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if _, exists := seen[field]; exists {
|
|
continue
|
|
}
|
|
seen[field] = struct{}{}
|
|
}
|
|
|
|
return int64(len(seen))
|
|
}
|
|
|
|
// getTemplate 获取单个模板详情
|
|
func (api *TemplatesAPI) getTemplate(w http.ResponseWriter, r *http.Request, templateID string) {
|
|
querySQL := `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=?`
|
|
|
|
row := api.metaDB.QueryRow(querySQL, templateID)
|
|
|
|
var (
|
|
id uint64
|
|
name string
|
|
datasource string
|
|
mainTable string
|
|
fileFormat string
|
|
visibility string
|
|
ownerID uint64
|
|
enabled int
|
|
explainScore sql.NullInt64
|
|
lastValidatedAt sql.NullTime
|
|
createdAt time.Time
|
|
updatedAt time.Time
|
|
fieldsJSON []byte
|
|
filtersJSON []byte
|
|
)
|
|
|
|
err := row.Scan(&id, &name, &datasource, &mainTable, &fieldsJSON, &filtersJSON,
|
|
&fileFormat, &visibility, &ownerID, &enabled, &explainScore,
|
|
&lastValidatedAt, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"id": id,
|
|
"name": name,
|
|
"datasource": datasource,
|
|
"main_table": mainTable,
|
|
"file_format": fileFormat,
|
|
"visibility": visibility,
|
|
"owner_id": ownerID,
|
|
"enabled": enabled == 1,
|
|
"explain_score": explainScore.Int64,
|
|
"last_validated_at": lastValidatedAt.Time,
|
|
"created_at": createdAt,
|
|
"updated_at": updatedAt,
|
|
"fields": fromJSON(fieldsJSON),
|
|
"filters": fromJSON(filtersJSON),
|
|
}
|
|
|
|
ok(w, r, result)
|
|
}
|
|
|
|
// patchTemplate 更新模板
|
|
func (api *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, templateID string) {
|
|
traceID := TraceIDFrom(r)
|
|
|
|
// 读取请求体
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("trace_id=%s error reading request body: %v", traceID, err)
|
|
fail(w, r, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate request body: %s", traceID, string(body))
|
|
|
|
// 解析JSON
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
log.Printf("trace_id=%s error unmarshaling request body: %v", traceID, err)
|
|
fail(w, r, http.StatusBadRequest, "invalid JSON format")
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate parsed payload: %v", traceID, payload)
|
|
log.Printf("trace_id=%s patchTemplate template ID: %s", traceID, templateID)
|
|
|
|
// 构建UPDATE语句
|
|
var setClauses []string
|
|
var args []interface{}
|
|
|
|
for key, value := range payload {
|
|
log.Printf("trace_id=%s patchTemplate processing field: %s, value: %v, type: %T", traceID, key, value, value)
|
|
|
|
switch key {
|
|
case "name", "visibility", "file_format", "main_table":
|
|
if strVal, isStr := value.(string); isStr {
|
|
setClauses = append(setClauses, key+"=?")
|
|
args = append(args, strVal)
|
|
log.Printf("trace_id=%s patchTemplate added string field: %s, value: %s", traceID, key, strVal)
|
|
} else {
|
|
log.Printf("trace_id=%s patchTemplate invalid string field: %s, value: %v, type: %T", traceID, key, value, value)
|
|
}
|
|
|
|
case "fields":
|
|
setClauses = append(setClauses, "fields_json=?")
|
|
jsonBytes := toJSON(value)
|
|
args = append(args, jsonBytes)
|
|
log.Printf("trace_id=%s patchTemplate added fields_json: %s", traceID, string(jsonBytes))
|
|
|
|
case "filters":
|
|
setClauses = append(setClauses, "filters_json=?")
|
|
jsonBytes := toJSON(value)
|
|
args = append(args, jsonBytes)
|
|
log.Printf("trace_id=%s patchTemplate added filters_json: %s", traceID, string(jsonBytes))
|
|
|
|
case "enabled":
|
|
setClauses = append(setClauses, "enabled=?")
|
|
if boolVal, isBool := value.(bool); isBool {
|
|
if boolVal {
|
|
args = append(args, 1)
|
|
} else {
|
|
args = append(args, 0)
|
|
}
|
|
log.Printf("trace_id=%s patchTemplate added enabled: %t", traceID, boolVal)
|
|
} else {
|
|
log.Printf("trace_id=%s patchTemplate invalid bool field: %s, value: %v, type: %T", traceID, key, value, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(setClauses) == 0 {
|
|
log.Printf("trace_id=%s patchTemplate no fields to update", traceID)
|
|
fail(w, r, http.StatusBadRequest, "no patch")
|
|
return
|
|
}
|
|
|
|
// 添加updated_at
|
|
setClauses = append(setClauses, "updated_at=?")
|
|
now := time.Now()
|
|
args = append(args, now, templateID)
|
|
|
|
updateSQL := "UPDATE export_templates SET " + strings.Join(setClauses, ",") + " WHERE id= ?"
|
|
log.Printf("trace_id=%s patchTemplate executing SQL: %s", traceID, updateSQL)
|
|
log.Printf("trace_id=%s patchTemplate SQL args: %v", traceID, args)
|
|
|
|
if _, err := api.metaDB.Exec(updateSQL, args...); err != nil {
|
|
log.Printf("trace_id=%s patchTemplate SQL error: %v", traceID, err)
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate update successful", traceID)
|
|
ok(w, r, nil)
|
|
}
|
|
|
|
// deleteTemplate 删除模板
|
|
func (api *TemplatesAPI) deleteTemplate(w http.ResponseWriter, r *http.Request, templateID string) {
|
|
// 检查是否有关联的导出任务
|
|
var jobCount int64
|
|
row := api.metaDB.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id=?", templateID)
|
|
_ = row.Scan(&jobCount)
|
|
|
|
if jobCount > 0 {
|
|
// 有关联任务,检查是否要求软删除
|
|
softDelete := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("soft")))
|
|
if softDelete == "1" || softDelete == "true" || softDelete == "yes" {
|
|
// 软删除:禁用模板
|
|
_, _ = api.metaDB.Exec("UPDATE export_templates SET enabled=?, updated_at=? WHERE id=?", 0, time.Now(), templateID)
|
|
ok(w, r, nil)
|
|
return
|
|
}
|
|
fail(w, r, http.StatusBadRequest, "template in use")
|
|
return
|
|
}
|
|
|
|
// 无关联任务,硬删除
|
|
if _, err := api.metaDB.Exec("DELETE FROM export_templates WHERE id=?", templateID); err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
ok(w, r, nil)
|
|
}
|
|
|
|
// validateTemplate 验证模板
|
|
func (api *TemplatesAPI) validateTemplate(w http.ResponseWriter, r *http.Request, templateID string) {
|
|
// 获取模板信息
|
|
row := api.metaDB.QueryRow(
|
|
"SELECT datasource, main_table, fields_json, filters_json FROM export_templates WHERE id=?",
|
|
templateID,
|
|
)
|
|
|
|
var (
|
|
datasource string
|
|
mainTable string
|
|
fieldsJSON []byte
|
|
filtersJSON []byte
|
|
)
|
|
|
|
if err := row.Scan(&datasource, &mainTable, &fieldsJSON, &filtersJSON); err != nil {
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
// 解析字段和过滤条件
|
|
var fields []string
|
|
var filters map[string]interface{}
|
|
_ = json.Unmarshal(fieldsJSON, &fields)
|
|
_ = json.Unmarshal(filtersJSON, &filters)
|
|
|
|
// 构建SQL
|
|
whitelist := Whitelist()
|
|
request := exporter.BuildRequest{
|
|
MainTable: mainTable,
|
|
Datasource: datasource,
|
|
Fields: fields,
|
|
Filters: filters,
|
|
}
|
|
|
|
query, args, err := exporter.BuildSQL(request, whitelist)
|
|
if err != nil {
|
|
failCat(w, r, http.StatusBadRequest, err.Error(), "sql_build_error")
|
|
return
|
|
}
|
|
|
|
// 执行EXPLAIN分析
|
|
dataDB := api.selectDataDB(datasource)
|
|
score, suggestions, err := exporter.EvaluateExplain(dataDB, query, args)
|
|
if err != nil {
|
|
failCat(w, r, http.StatusBadRequest, err.Error(), "explain_error")
|
|
return
|
|
}
|
|
|
|
// 添加索引建议
|
|
indexSuggestions := exporter.IndexSuggestions(request)
|
|
suggestions = append(suggestions, indexSuggestions...)
|
|
|
|
// 更新模板的验证结果
|
|
explainResult := map[string]interface{}{
|
|
"sql": query,
|
|
"suggestions": suggestions,
|
|
}
|
|
now := time.Now()
|
|
_, _ = api.metaDB.Exec(
|
|
"UPDATE export_templates SET explain_json=?, explain_score=?, last_validated_at=?, updated_at=? WHERE id=?",
|
|
toJSON(explainResult), score, now, now, templateID,
|
|
)
|
|
|
|
ok(w, r, map[string]interface{}{
|
|
"score": score,
|
|
"suggestions": suggestions,
|
|
})
|
|
}
|
|
|
|
// selectDataDB 根据数据源选择对应的数据库连接
|
|
func (api *TemplatesAPI) selectDataDB(datasource string) *sql.DB {
|
|
if datasource == "ymt" {
|
|
return api.metaDB // YMT数据在meta库
|
|
}
|
|
return api.marketingDB
|
|
}
|
|
|
|
// ==================== 辅助函数 ====================
|
|
|
|
// toJSON 将对象转换为JSON字节
|
|
func toJSON(v interface{}) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// fromJSON 将JSON字节解析为对象
|
|
func fromJSON(b []byte) interface{} {
|
|
var v interface{}
|
|
_ = json.Unmarshal(b, &v)
|
|
return v
|
|
}
|
|
|
|
// Whitelist 获取字段白名单
|
|
func Whitelist() map[string]bool {
|
|
return schema.AllWhitelist()
|
|
}
|
|
|
|
// FieldLabels 获取字段标签映射
|
|
func FieldLabels() map[string]string {
|
|
return schema.AllLabels()
|
|
}
|