Compare commits
2 Commits
6fa4abdcf5
...
23bfdfc645
| Author | SHA1 | Date |
|---|---|---|
|
|
23bfdfc645 | |
|
|
950fa758e1 |
|
|
@ -1,13 +1,17 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"marketing-system-data-tool/server/internal/exporter"
|
"marketing-system-data-tool/server/internal/exporter"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -33,6 +37,11 @@ func ExportsHandler(meta, marketing *sql.DB) http.Handler {
|
||||||
if strings.HasPrefix(p, "/") {
|
if strings.HasPrefix(p, "/") {
|
||||||
id := strings.TrimPrefix(p, "/")
|
id := strings.TrimPrefix(p, "/")
|
||||||
if r.Method == http.MethodGet && !strings.HasSuffix(p, "/download") {
|
if r.Method == http.MethodGet && !strings.HasSuffix(p, "/download") {
|
||||||
|
if strings.HasSuffix(p, "/sql") {
|
||||||
|
id = strings.TrimSuffix(id, "/sql")
|
||||||
|
api.getSQL(w, r, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
api.get(w, r, id)
|
api.get(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -102,9 +111,35 @@ func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var estimate int64
|
var estimate int64
|
||||||
|
func() {
|
||||||
|
idx := strings.Index(q, " FROM ")
|
||||||
|
if idx > 0 {
|
||||||
|
cq := "SELECT COUNT(1)" + q[idx:]
|
||||||
|
row := dataDB.QueryRow(cq, args...)
|
||||||
|
var cnt int64
|
||||||
|
if err := row.Scan(&cnt); err == nil {
|
||||||
|
estimate = cnt
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, r := range expRows {
|
for _, r := range expRows {
|
||||||
if r.Table.Valid && r.Table.String == "order" && r.Rows.Valid { estimate = r.Rows.Int64; break }
|
if r.Table.Valid && r.Table.String == "order" && r.Rows.Valid {
|
||||||
if r.Rows.Valid { estimate += r.Rows.Int64 }
|
estimate = r.Rows.Int64
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if r.Rows.Valid {
|
||||||
|
estimate += r.Rows.Int64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
labels := fieldLabels()
|
||||||
|
hdrs := make([]string, len(fs))
|
||||||
|
for i, tf := range fs {
|
||||||
|
if v, ok := labels[tf]; ok {
|
||||||
|
hdrs[i] = v
|
||||||
|
} else {
|
||||||
|
hdrs[i] = tf
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ejSQL := "INSERT INTO export_jobs (template_id, status, requested_by, permission_scope_json, filters_json, options_json, explain_json, explain_score, row_estimate, file_format, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
|
ejSQL := "INSERT INTO export_jobs (template_id, status, requested_by, permission_scope_json, filters_json, options_json, explain_json, explain_score, row_estimate, file_format, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||||
ejArgs := []interface{}{p.TemplateID, "queued", p.RequestedBy, toJSON(p.Permission), toJSON(p.Filters), toJSON(p.Options), toJSON(expRows), score, estimate, p.FileFormat, time.Now(), time.Now()}
|
ejArgs := []interface{}{p.TemplateID, "queued", p.RequestedBy, toJSON(p.Permission), toJSON(p.Filters), toJSON(p.Options), toJSON(expRows), score, estimate, p.FileFormat, time.Now(), time.Now()}
|
||||||
|
|
@ -115,11 +150,11 @@ func (a *ExportsAPI) create(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
go a.runJob(uint64(id), dataDB, q, args, fs, p.FileFormat)
|
go a.runJob(uint64(id), dataDB, q, args, fs, hdrs, p.FileFormat)
|
||||||
ok(w, r, map[string]interface{}{"id": id})
|
ok(w, r, map[string]interface{}{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{}, cols []string, fmt string) {
|
func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{}, fields []string, cols []string, fmt string) {
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, "UPDATE export_jobs SET status=?, started_at=? WHERE id= ?", []interface{}{"running", time.Now(), id})
|
log.Printf("job_id=%d sql=%s args=%v", id, "UPDATE export_jobs SET status=?, started_at=? WHERE id= ?", []interface{}{"running", time.Now(), id})
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, started_at=?, updated_at=? WHERE id= ?", "running", time.Now(), time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, started_at=?, updated_at=? WHERE id= ?", "running", time.Now(), time.Now(), id)
|
||||||
if fmt == "csv" {
|
if fmt == "csv" {
|
||||||
|
|
@ -129,7 +164,290 @@ func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{},
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(cols)
|
w.WriteHeader(cols)
|
||||||
|
const maxRowsPerFile = 300000
|
||||||
|
files := []string{}
|
||||||
|
{
|
||||||
|
var tplID uint64
|
||||||
|
var filtersJSON []byte
|
||||||
|
row := a.meta.QueryRow("SELECT template_id, filters_json FROM export_jobs WHERE id=?", id)
|
||||||
|
_ = row.Scan(&tplID, &filtersJSON)
|
||||||
|
var main string
|
||||||
|
var fieldsJSON []byte
|
||||||
|
tr := a.meta.QueryRow("SELECT main_table, fields_json FROM export_templates WHERE id=?", tplID)
|
||||||
|
_ = tr.Scan(&main, &fieldsJSON)
|
||||||
|
var fs []string
|
||||||
|
var fl map[string]interface{}
|
||||||
|
json.Unmarshal(fieldsJSON, &fs)
|
||||||
|
json.Unmarshal(filtersJSON, &fl)
|
||||||
|
wl := whitelist()
|
||||||
|
var chunks [][2]string
|
||||||
|
if v, ok := fl["create_time_between"]; ok {
|
||||||
|
if arr, ok2 := v.([]interface{}); ok2 && len(arr) == 2 {
|
||||||
|
chunks = splitByDays(toString(arr[0]), toString(arr[1]), 10)
|
||||||
|
}
|
||||||
|
if arrs, ok3 := v.([]string); ok3 && len(arrs) == 2 {
|
||||||
|
chunks = splitByDays(arrs[0], arrs[1], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chunks) > 0 {
|
||||||
|
out := make([]interface{}, len(cols))
|
||||||
|
dest := make([]interface{}, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
dest[i] = &out[i]
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
var partCount int64
|
||||||
|
var tick int64
|
||||||
|
for _, rg := range chunks {
|
||||||
|
fl["create_time_between"] = []string{rg[0], rg[1]}
|
||||||
|
req := exporter.BuildRequest{MainTable: main, Fields: fs, Filters: fl}
|
||||||
|
cq, cargs, err := exporter.BuildSQL(req, wl)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch := 1000
|
||||||
|
for off := 0; ; off += batch {
|
||||||
|
sub := "SELECT * FROM (" + cq + ") AS sub LIMIT ? OFFSET ?"
|
||||||
|
args2 := append(append([]interface{}{}, cargs...), batch, off)
|
||||||
|
rows2, err := db.Query(sub, args2...)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fetched := false
|
||||||
|
for rows2.Next() {
|
||||||
|
fetched = true
|
||||||
|
if err := rows2.Scan(dest...); err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vals := make([]string, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
if b, ok := out[i].([]byte); ok {
|
||||||
|
vals[i] = string(b)
|
||||||
|
} else if out[i] == nil {
|
||||||
|
vals[i] = ""
|
||||||
|
} else {
|
||||||
|
vals[i] = toString(out[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vals = transformRow(fs, vals)
|
||||||
|
vals = transformRow(fields, vals)
|
||||||
|
vals = transformRow(fields, vals)
|
||||||
|
vals = transformRow(fields, vals)
|
||||||
|
w.WriteRow(vals)
|
||||||
|
count++
|
||||||
|
partCount++
|
||||||
|
tick++
|
||||||
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
|
if partCount >= maxRowsPerFile {
|
||||||
|
path, size, _ := w.Close()
|
||||||
|
files = append(files, path)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path, partCount, size, time.Now(), time.Now())
|
||||||
|
w, err = exporter.NewCSVWriter("storage", "export")
|
||||||
|
if err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(cols)
|
||||||
|
partCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows2.Close()
|
||||||
|
if !fetched {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path, size, _ := w.Close()
|
||||||
|
if partCount > 0 || len(files) == 0 {
|
||||||
|
files = append(files, path)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path, partCount, size, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
row := db.QueryRow("SELECT COUNT(1) FROM ("+q+") AS sub", args...)
|
||||||
|
var c int64
|
||||||
|
_ = row.Scan(&c)
|
||||||
|
count = c
|
||||||
|
}
|
||||||
|
if len(files) >= 1 {
|
||||||
|
zipPath, zipSize := createZip(id, files)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, zipPath, count, zipSize, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, q, args)
|
log.Printf("job_id=%d sql=%s args=%v", id, q, args)
|
||||||
|
// batched cursor queries, split workbook per 300k rows
|
||||||
|
{
|
||||||
|
const maxRowsPerFile = 300000
|
||||||
|
out := make([]interface{}, len(cols))
|
||||||
|
dest := make([]interface{}, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
dest[i] = &out[i]
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
var partCount int64
|
||||||
|
var tick int64
|
||||||
|
batch := 1000
|
||||||
|
files2 := []string{}
|
||||||
|
for off := 0; ; off += batch {
|
||||||
|
sub := "SELECT * FROM (" + q + ") AS sub LIMIT ? OFFSET ?"
|
||||||
|
args2 := append(append([]interface{}{}, args...), batch, off)
|
||||||
|
rows3, err := db.Query(sub, args2...)
|
||||||
|
if err != nil {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetched := false
|
||||||
|
for rows3.Next() {
|
||||||
|
fetched = true
|
||||||
|
if err := rows3.Scan(dest...); err != nil {
|
||||||
|
rows3.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vals := make([]string, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
if b, ok := out[i].([]byte); ok {
|
||||||
|
vals[i] = string(b)
|
||||||
|
} else if out[i] == nil {
|
||||||
|
vals[i] = ""
|
||||||
|
} else {
|
||||||
|
vals[i] = toString(out[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteRow(vals)
|
||||||
|
count++
|
||||||
|
partCount++
|
||||||
|
tick++
|
||||||
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
|
if partCount >= maxRowsPerFile {
|
||||||
|
path2, size2, _ := w.Close()
|
||||||
|
files2 = append(files2, path2)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path2, partCount, size2, time.Now(), time.Now())
|
||||||
|
w, err = exporter.NewCSVWriter("storage", "export")
|
||||||
|
if err != nil {
|
||||||
|
rows3.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(cols)
|
||||||
|
partCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows3.Close()
|
||||||
|
if !fetched {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path, size, _ := w.Close()
|
||||||
|
if partCount > 0 || len(files2) == 0 {
|
||||||
|
files2 = append(files2, path)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path, partCount, size, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
row := db.QueryRow("SELECT COUNT(1) FROM ("+q+") AS sub", args...)
|
||||||
|
var c int64
|
||||||
|
_ = row.Scan(&c)
|
||||||
|
count = c
|
||||||
|
}
|
||||||
|
if len(files2) >= 1 {
|
||||||
|
zipPath, zipSize := createZip(id, files2)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, zipPath, count, zipSize, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// batched cursor queries, 1000 rows per page, file split at 300k
|
||||||
|
{
|
||||||
|
const maxRowsPerFile = 300000
|
||||||
|
files2 := []string{}
|
||||||
|
out := make([]interface{}, len(cols))
|
||||||
|
dest := make([]interface{}, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
dest[i] = &out[i]
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
var partCount int64
|
||||||
|
var tick int64
|
||||||
|
batch := 1000
|
||||||
|
for off := 0; ; off += batch {
|
||||||
|
sub := "SELECT * FROM (" + q + ") AS sub LIMIT ? OFFSET ?"
|
||||||
|
args2 := append(append([]interface{}{}, args...), batch, off)
|
||||||
|
rows3, err := db.Query(sub, args2...)
|
||||||
|
if err != nil {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetched := false
|
||||||
|
for rows3.Next() {
|
||||||
|
fetched = true
|
||||||
|
if err := rows3.Scan(dest...); err != nil {
|
||||||
|
rows3.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vals := make([]string, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
if b, ok := out[i].([]byte); ok {
|
||||||
|
vals[i] = string(b)
|
||||||
|
} else if out[i] == nil {
|
||||||
|
vals[i] = ""
|
||||||
|
} else {
|
||||||
|
vals[i] = toString(out[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteRow(vals)
|
||||||
|
count++
|
||||||
|
partCount++
|
||||||
|
tick++
|
||||||
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
|
if partCount >= maxRowsPerFile {
|
||||||
|
path, size, _ := w.Close()
|
||||||
|
files2 = append(files2, path)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path, partCount, size, time.Now(), time.Now())
|
||||||
|
w, err = exporter.NewCSVWriter("storage", "export")
|
||||||
|
if err != nil {
|
||||||
|
rows3.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(cols)
|
||||||
|
partCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows3.Close()
|
||||||
|
if !fetched {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path, size, _ := w.Close()
|
||||||
|
if partCount > 0 || len(files2) == 0 {
|
||||||
|
files2 = append(files2, path)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, path, partCount, size, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
row := db.QueryRow("SELECT COUNT(1) FROM ("+q+") AS sub", args...)
|
||||||
|
var c int64
|
||||||
|
_ = row.Scan(&c)
|
||||||
|
count = c
|
||||||
|
}
|
||||||
|
if len(files2) >= 1 {
|
||||||
|
zipPath, zipSize := createZip(id, files2)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, zipPath, count, zipSize, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
|
@ -161,7 +479,9 @@ func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{},
|
||||||
w.WriteRow(vals)
|
w.WriteRow(vals)
|
||||||
count++
|
count++
|
||||||
tick++
|
tick++
|
||||||
if tick%500 == 0 { a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id) }
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
path, size, _ := w.Close()
|
path, size, _ := w.Close()
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, "INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", []interface{}{id, path, count, size, time.Now(), time.Now()})
|
log.Printf("job_id=%d sql=%s args=%v", id, "INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", []interface{}{id, path, count, size, time.Now(), time.Now()})
|
||||||
|
|
@ -171,12 +491,169 @@ func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{},
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fmt == "xlsx" {
|
if fmt == "xlsx" {
|
||||||
|
const maxRowsPerFile = 300000
|
||||||
|
files := []string{}
|
||||||
x, path, err := exporter.NewXLSXWriter("storage", "export", "Sheet1")
|
x, path, err := exporter.NewXLSXWriter("storage", "export", "Sheet1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x.WriteHeader(cols)
|
x.WriteHeader(cols)
|
||||||
|
{
|
||||||
|
var tplID uint64
|
||||||
|
var filtersJSON []byte
|
||||||
|
row := a.meta.QueryRow("SELECT template_id, filters_json FROM export_jobs WHERE id=?", id)
|
||||||
|
_ = row.Scan(&tplID, &filtersJSON)
|
||||||
|
var main string
|
||||||
|
var fieldsJSON []byte
|
||||||
|
tr := a.meta.QueryRow("SELECT main_table, fields_json FROM export_templates WHERE id=?", tplID)
|
||||||
|
_ = tr.Scan(&main, &fieldsJSON)
|
||||||
|
var fs []string
|
||||||
|
var fl map[string]interface{}
|
||||||
|
json.Unmarshal(fieldsJSON, &fs)
|
||||||
|
json.Unmarshal(filtersJSON, &fl)
|
||||||
|
wl := whitelist()
|
||||||
|
var chunks [][2]string
|
||||||
|
if v, ok := fl["create_time_between"]; ok {
|
||||||
|
if arr, ok2 := v.([]interface{}); ok2 && len(arr) == 2 {
|
||||||
|
chunks = splitByDays(toString(arr[0]), toString(arr[1]), 10)
|
||||||
|
}
|
||||||
|
if arrs, ok3 := v.([]string); ok3 && len(arrs) == 2 {
|
||||||
|
chunks = splitByDays(arrs[0], arrs[1], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chunks) > 0 {
|
||||||
|
out := make([]interface{}, len(cols))
|
||||||
|
dest := make([]interface{}, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
dest[i] = &out[i]
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
var partCount int64
|
||||||
|
var tick int64
|
||||||
|
for _, rg := range chunks {
|
||||||
|
fl["create_time_between"] = []string{rg[0], rg[1]}
|
||||||
|
req := exporter.BuildRequest{MainTable: main, Fields: fs, Filters: fl}
|
||||||
|
cq, cargs, err := exporter.BuildSQL(req, wl)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch := 1000
|
||||||
|
for off := 0; ; off += batch {
|
||||||
|
sub := "SELECT * FROM (" + cq + ") AS sub LIMIT ? OFFSET ?"
|
||||||
|
args2 := append(append([]interface{}{}, cargs...), batch, off)
|
||||||
|
rows2, err := db.Query(sub, args2...)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fetched := false
|
||||||
|
for rows2.Next() {
|
||||||
|
fetched = true
|
||||||
|
if err := rows2.Scan(dest...); err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vals := make([]string, len(cols))
|
||||||
|
for i := range out {
|
||||||
|
if b, ok := out[i].([]byte); ok {
|
||||||
|
vals[i] = string(b)
|
||||||
|
} else if out[i] == nil {
|
||||||
|
vals[i] = ""
|
||||||
|
} else {
|
||||||
|
vals[i] = toString(out[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vals = transformRow(fs, vals)
|
||||||
|
vals = transformRow(fs, vals)
|
||||||
|
vals = transformRow(fs, vals)
|
||||||
|
vals = transformRow(fs, vals)
|
||||||
|
vals = transformRow(fields, vals)
|
||||||
|
x.WriteRow(vals)
|
||||||
|
count++
|
||||||
|
partCount++
|
||||||
|
tick++
|
||||||
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
|
if partCount >= maxRowsPerFile {
|
||||||
|
p, size, _ := x.Close(path)
|
||||||
|
files = append(files, p)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, p, partCount, size, time.Now(), time.Now())
|
||||||
|
x, path, err = exporter.NewXLSXWriter("storage", "export", "Sheet1")
|
||||||
|
if err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id= ?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x.WriteHeader(cols)
|
||||||
|
partCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows2.Close()
|
||||||
|
if !fetched {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
rows, err := db.Query(q, args...)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
out2 := make([]interface{}, len(cols))
|
||||||
|
dest2 := make([]interface{}, len(cols))
|
||||||
|
for i := range out2 {
|
||||||
|
dest2[i] = &out2[i]
|
||||||
|
}
|
||||||
|
var tick2 int64
|
||||||
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(dest2...); err != nil {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id?", "failed", time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vals := make([]string, len(cols))
|
||||||
|
for i := range out2 {
|
||||||
|
if b, ok := out2[i].([]byte); ok {
|
||||||
|
vals[i] = string(b)
|
||||||
|
} else if out2[i] == nil {
|
||||||
|
vals[i] = ""
|
||||||
|
} else {
|
||||||
|
vals[i] = toString(out2[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x.WriteRow(vals)
|
||||||
|
count++
|
||||||
|
tick2++
|
||||||
|
if tick2%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, size, _ := x.Close(path)
|
||||||
|
if partCount > 0 || len(files) == 0 {
|
||||||
|
files = append(files, p)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, p, func() int64 {
|
||||||
|
if count > 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
return partCount
|
||||||
|
}(), size, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
row := db.QueryRow("SELECT COUNT(1) FROM ("+q+") AS sub", args...)
|
||||||
|
var c int64
|
||||||
|
_ = row.Scan(&c)
|
||||||
|
count = c
|
||||||
|
}
|
||||||
|
if len(files) >= 1 {
|
||||||
|
zipPath, zipSize := createZip(id, files)
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, zipPath, count, zipSize, time.Now(), time.Now())
|
||||||
|
}
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, q, args)
|
log.Printf("job_id=%d sql=%s args=%v", id, q, args)
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -190,6 +667,7 @@ func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{},
|
||||||
dest[i] = &out[i]
|
dest[i] = &out[i]
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
|
var tick int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
if err := rows.Scan(dest...); err != nil {
|
if err := rows.Scan(dest...); err != nil {
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id=?", "failed", time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=? WHERE id=?", "failed", time.Now(), id)
|
||||||
|
|
@ -207,10 +685,16 @@ func (a *ExportsAPI) runJob(id uint64, db *sql.DB, q string, args []interface{},
|
||||||
}
|
}
|
||||||
x.WriteRow(vals)
|
x.WriteRow(vals)
|
||||||
count++
|
count++
|
||||||
|
tick++
|
||||||
|
if tick%50 == 0 {
|
||||||
|
a.meta.Exec("UPDATE export_jobs SET total_rows=?, updated_at=? WHERE id= ?", count, time.Now(), id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p, size, _ := x.Close(path)
|
p, size, _ := x.Close(path)
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, "INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", []interface{}{id, p, count, size, time.Now(), time.Now()})
|
log.Printf("job_id=%d sql=%s args=%v", id, "INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", []interface{}{id, p, count, size, time.Now(), time.Now()})
|
||||||
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, p, count, size, time.Now(), time.Now())
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, p, count, size, time.Now(), time.Now())
|
||||||
|
zipPath, zipSize := createZip(id, []string{p})
|
||||||
|
a.meta.Exec("INSERT INTO export_job_files (job_id, storage_uri, row_count, size_bytes, created_at, updated_at) VALUES (?,?,?,?,?,?)", id, zipPath, count, zipSize, time.Now(), time.Now())
|
||||||
log.Printf("job_id=%d sql=%s args=%v", id, "UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", []interface{}{"completed", time.Now(), count, time.Now(), id})
|
log.Printf("job_id=%d sql=%s args=%v", id, "UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", []interface{}{"completed", time.Now(), count, time.Now(), id})
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, finished_at=?, total_rows=?, updated_at=? WHERE id= ?", "completed", time.Now(), count, time.Now(), id)
|
||||||
return
|
return
|
||||||
|
|
@ -225,6 +709,28 @@ func (a *ExportsAPI) selectDataDB(ds string) *sql.DB {
|
||||||
return a.marketing
|
return a.marketing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitByDays(startStr, endStr string, stepDays int) [][2]string {
|
||||||
|
layout := "2006-01-02 15:04:05"
|
||||||
|
s, es := strings.TrimSpace(startStr), strings.TrimSpace(endStr)
|
||||||
|
st, err1 := time.Parse(layout, s)
|
||||||
|
en, err2 := time.Parse(layout, es)
|
||||||
|
if err1 != nil || err2 != nil || !en.After(st) || stepDays <= 0 {
|
||||||
|
return [][2]string{{s, es}}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ExportsAPI) get(w http.ResponseWriter, r *http.Request, id string) {
|
func (a *ExportsAPI) get(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
row := a.meta.QueryRow("SELECT id, template_id, status, requested_by, total_rows, file_format, started_at, finished_at, created_at, updated_at FROM export_jobs WHERE id=?", id)
|
row := a.meta.QueryRow("SELECT id, template_id, status, requested_by, total_rows, file_format, started_at, finished_at, created_at, updated_at FROM export_jobs WHERE id=?", id)
|
||||||
var m = map[string]interface{}{}
|
var m = map[string]interface{}{}
|
||||||
|
|
@ -264,6 +770,70 @@ func (a *ExportsAPI) get(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
ok(w, r, m)
|
ok(w, r, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ExportsAPI) getSQL(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
// load job filters and template fields
|
||||||
|
row := a.meta.QueryRow("SELECT template_id, filters_json FROM export_jobs WHERE id=?", id)
|
||||||
|
var tplID uint64
|
||||||
|
var filters []byte
|
||||||
|
if err := row.Scan(&tplID, &filters); err != nil {
|
||||||
|
fail(w, r, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := a.meta.QueryRow("SELECT main_table, fields_json FROM export_templates WHERE id=?", tplID)
|
||||||
|
var main string
|
||||||
|
var fields []byte
|
||||||
|
if err := tr.Scan(&main, &fields); err != nil {
|
||||||
|
fail(w, r, http.StatusBadRequest, "template 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, Fields: fs, Filters: fl}
|
||||||
|
q, args, err := exporter.BuildSQL(req, wl)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, r, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formatArg := func(a interface{}) string {
|
||||||
|
switch t := a.(type) {
|
||||||
|
case nil:
|
||||||
|
return "NULL"
|
||||||
|
case []byte:
|
||||||
|
s := string(t)
|
||||||
|
s = strings.ReplaceAll(s, "'", "''")
|
||||||
|
return "'" + s + "'"
|
||||||
|
case string:
|
||||||
|
s := strings.ReplaceAll(t, "'", "''")
|
||||||
|
return "'" + s + "'"
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(t)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(t, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||||
|
case time.Time:
|
||||||
|
return "'" + t.Format("2006-01-02 15:04:05") + "'"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
var ai int
|
||||||
|
for i := 0; i < len(q); i++ {
|
||||||
|
c := q[i]
|
||||||
|
if c == '?' && ai < len(args) {
|
||||||
|
sb.WriteString(formatArg(args[ai]))
|
||||||
|
ai++
|
||||||
|
} else {
|
||||||
|
sb.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok(w, r, map[string]interface{}{"sql": q, "final_sql": sb.String()})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ExportsAPI) download(w http.ResponseWriter, r *http.Request, id string) {
|
func (a *ExportsAPI) download(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
row := a.meta.QueryRow("SELECT storage_uri FROM export_job_files WHERE job_id=? ORDER BY id DESC LIMIT 1", id)
|
row := a.meta.QueryRow("SELECT storage_uri FROM export_job_files WHERE job_id=? ORDER BY id DESC LIMIT 1", id)
|
||||||
var uri string
|
var uri string
|
||||||
|
|
@ -275,6 +845,49 @@ func (a *ExportsAPI) download(w http.ResponseWriter, r *http.Request, id string)
|
||||||
http.ServeFile(w, r, uri)
|
http.ServeFile(w, r, uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func transformRow(fields []string, vals []string) []string {
|
||||||
|
for i := range fields {
|
||||||
|
if i >= len(vals) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
f := fields[i]
|
||||||
|
if f == "order.key" {
|
||||||
|
vals[i] = decodeOrderKey(vals[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOrderKey(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if len(s) > 2 && s[len(s)-2:] == "_1" {
|
||||||
|
s = s[:len(s)-2]
|
||||||
|
}
|
||||||
|
var n big.Int
|
||||||
|
if _, ok := n.SetString(s, 10); !ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
base := []rune{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
|
||||||
|
baseCount := big.NewInt(int64(len(base)))
|
||||||
|
zero := big.NewInt(0)
|
||||||
|
var out []rune
|
||||||
|
for n.Cmp(zero) > 0 {
|
||||||
|
var mod big.Int
|
||||||
|
mod.Mod(&n, baseCount)
|
||||||
|
out = append(out, base[mod.Int64()])
|
||||||
|
n.Div(&n, baseCount)
|
||||||
|
}
|
||||||
|
for len(out) < 16 {
|
||||||
|
out = append(out, base[0])
|
||||||
|
}
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ExportsAPI) cancel(w http.ResponseWriter, r *http.Request, id string) {
|
func (a *ExportsAPI) cancel(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
a.meta.Exec("UPDATE export_jobs SET status=?, updated_at=? WHERE id=? AND status IN ('queued','running')", "canceled", time.Now(), id)
|
a.meta.Exec("UPDATE export_jobs SET status=?, updated_at=? WHERE id=? AND status IN ('queued','running')", "canceled", time.Now(), id)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
|
|
@ -292,8 +905,15 @@ func toString(v interface{}) string {
|
||||||
return strconv.Itoa(t)
|
return strconv.Itoa(t)
|
||||||
case float64:
|
case float64:
|
||||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
case time.Time:
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
default:
|
default:
|
||||||
return ""
|
return fmt.Sprintf("%v", t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (a *ExportsAPI) list(w http.ResponseWriter, r *http.Request) {
|
func (a *ExportsAPI) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -301,15 +921,21 @@ func (a *ExportsAPI) list(w http.ResponseWriter, r *http.Request) {
|
||||||
page := 1
|
page := 1
|
||||||
size := 15
|
size := 15
|
||||||
if p := q.Get("page"); p != "" {
|
if p := q.Get("page"); p != "" {
|
||||||
if n, err := strconv.Atoi(p); err == nil && n > 0 { page = n }
|
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||||
|
page = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if s := q.Get("page_size"); s != "" {
|
if s := q.Get("page_size"); s != "" {
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { size = n }
|
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 {
|
||||||
|
size = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tplIDStr := q.Get("template_id")
|
tplIDStr := q.Get("template_id")
|
||||||
var tplID uint64
|
var tplID uint64
|
||||||
if tplIDStr != "" {
|
if tplIDStr != "" {
|
||||||
if n, err := strconv.ParseUint(tplIDStr, 10, 64); err == nil { tplID = n }
|
if n, err := strconv.ParseUint(tplIDStr, 10, 64); err == nil {
|
||||||
|
tplID = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
offset := (page - 1) * size
|
offset := (page - 1) * size
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
|
|
@ -323,9 +949,9 @@ func (a *ExportsAPI) list(w http.ResponseWriter, r *http.Request) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if tplID > 0 {
|
if tplID > 0 {
|
||||||
rows, err = a.meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score FROM export_jobs WHERE template_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", tplID, size, offset)
|
rows, err = a.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 {
|
} else {
|
||||||
rows, err = a.meta.Query("SELECT id, template_id, status, requested_by, row_estimate, total_rows, file_format, created_at, updated_at, explain_score FROM export_jobs ORDER BY id DESC LIMIT ? OFFSET ?", size, offset)
|
rows, err = a.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 {
|
if err != nil {
|
||||||
fail(w, r, http.StatusInternalServerError, err.Error())
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
|
@ -339,12 +965,117 @@ func (a *ExportsAPI) list(w http.ResponseWriter, r *http.Request) {
|
||||||
var estimate, total sql.NullInt64
|
var estimate, total sql.NullInt64
|
||||||
var createdAt, updatedAt sql.NullTime
|
var createdAt, updatedAt sql.NullTime
|
||||||
var score sql.NullInt64
|
var score sql.NullInt64
|
||||||
if err := rows.Scan(&id, &tid, &status, &req, &estimate, &total, &fmtstr, &createdAt, &updatedAt, &score); err != nil { continue }
|
var explainRaw sql.NullString
|
||||||
|
if err := rows.Scan(&id, &tid, &status, &req, &estimate, &total, &fmtstr, &createdAt, &updatedAt, &score, &explainRaw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
evalStatus := "通过"
|
evalStatus := "通过"
|
||||||
if score.Int64 < 60 { evalStatus = "禁止" }
|
if score.Int64 < 60 {
|
||||||
desc := fmt.Sprintf("评分:%d,估算行数:%d;%s", score.Int64, estimate.Int64, map[bool]string{true:"允许执行", false:"禁止执行"}[score.Int64>=60])
|
evalStatus = "禁止"
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf("评分:%d,估算行数:%d;%s", score.Int64, estimate.Int64, map[bool]string{true: "允许执行", false: "禁止执行"}[score.Int64 >= 60])
|
||||||
|
if explainRaw.Valid && explainRaw.String != "" {
|
||||||
|
var arr []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(explainRaw.String), &arr); err == nil {
|
||||||
|
segs := []string{}
|
||||||
|
for _, r := range arr {
|
||||||
|
getStr := func(field string) string {
|
||||||
|
if v, ok := r[field]; ok {
|
||||||
|
if mm, ok := v.(map[string]interface{}); ok {
|
||||||
|
if b, ok := mm["Valid"].(bool); ok && !b {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := mm["String"].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
getInt := func(field string) int64 {
|
||||||
|
if v, ok := r[field]; ok {
|
||||||
|
if mm, ok := v.(map[string]interface{}); ok {
|
||||||
|
if b, ok := mm["Valid"].(bool); ok && !b {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if f, ok := mm["Int64"].(float64); ok {
|
||||||
|
return int64(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
getFloat := func(field string) float64 {
|
||||||
|
if v, ok := r[field]; ok {
|
||||||
|
if mm, ok := v.(map[string]interface{}); ok {
|
||||||
|
if b, ok := mm["Valid"].(bool); ok && !b {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if f, ok := mm["Float64"].(float64); ok {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
tbl := getStr("Table")
|
||||||
|
typ := getStr("Type")
|
||||||
|
if typ == "" {
|
||||||
|
typ = getStr("SelectType")
|
||||||
|
}
|
||||||
|
key := getStr("Key")
|
||||||
|
rowsN := getInt("Rows")
|
||||||
|
filt := getFloat("Filtered")
|
||||||
|
extra := getStr("Extra")
|
||||||
|
if tbl == "" && typ == "" && rowsN == 0 && extra == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("表:%s, 访问类型:%s, 预估行数:%d, 索引:%s, 过滤比例:%.1f%%", tbl, typ, rowsN, key, filt)
|
||||||
|
if extra != "" {
|
||||||
|
s += ", 额外:" + extra
|
||||||
|
}
|
||||||
|
segs = append(segs, s)
|
||||||
|
}
|
||||||
|
if len(segs) > 0 {
|
||||||
|
desc = strings.Join(segs, ";")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
m := map[string]interface{}{"id": id, "template_id": tid, "status": status, "requested_by": req, "row_estimate": estimate.Int64, "total_rows": total.Int64, "file_format": fmtstr, "created_at": createdAt.Time, "updated_at": updatedAt.Time, "eval_status": evalStatus, "eval_desc": desc}
|
m := map[string]interface{}{"id": id, "template_id": tid, "status": status, "requested_by": req, "row_estimate": estimate.Int64, "total_rows": total.Int64, "file_format": fmtstr, "created_at": createdAt.Time, "updated_at": updatedAt.Time, "eval_status": evalStatus, "eval_desc": desc}
|
||||||
items = append(items, m)
|
items = append(items, m)
|
||||||
}
|
}
|
||||||
ok(w, r, map[string]interface{}{"items": items, "total": totalCount, "page": page, "page_size": size})
|
ok(w, r, map[string]interface{}{"items": items, "total": totalCount, "page": page, "page_size": size})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createZip(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
|
||||||
|
}
|
||||||
|
fi, _ := f.Stat()
|
||||||
|
w, err := zw.Create(filepath.Base(p))
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(w, f)
|
||||||
|
_ = fi
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
_ = zw.Close()
|
||||||
|
st, err := os.Stat(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return zipPath, 0
|
||||||
|
}
|
||||||
|
return zipPath, st.Size()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ func NewRouter(metaDB *sql.DB, marketingDB *sql.DB) http.Handler {
|
||||||
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/resellers/", withAccess(withTrace(ResellersHandler(marketingDB))))
|
mux.Handle("/api/resellers/", withAccess(withTrace(ResellersHandler(marketingDB))))
|
||||||
|
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()
|
sd := staticDir()
|
||||||
mux.Handle("/", http.FileServer(http.Dir(sd)))
|
mux.Handle("/", http.FileServer(http.Dir(sd)))
|
||||||
return mux
|
return mux
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,12 @@ func (a *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, id
|
||||||
case "name", "visibility", "file_format":
|
case "name", "visibility", "file_format":
|
||||||
set = append(set, k+"=?")
|
set = append(set, k+"=?")
|
||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
|
case "fields":
|
||||||
|
set = append(set, "fields_json=?")
|
||||||
|
args = append(args, toJSON(v))
|
||||||
|
case "filters":
|
||||||
|
set = append(set, "filters_json=?")
|
||||||
|
args = append(args, toJSON(v))
|
||||||
case "enabled":
|
case "enabled":
|
||||||
set = append(set, "enabled=?")
|
set = append(set, "enabled=?")
|
||||||
if v.(bool) {
|
if v.(bool) {
|
||||||
|
|
@ -163,7 +169,9 @@ func (a *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, id
|
||||||
fail(w, r, http.StatusBadRequest, "no patch")
|
fail(w, r, http.StatusBadRequest, "no patch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
args = append(args, id)
|
// ensure updated_at
|
||||||
|
set = append(set, "updated_at=?")
|
||||||
|
args = append(args, time.Now(), id)
|
||||||
_, err := a.meta.Exec("UPDATE export_templates SET "+strings.Join(set, ",")+" WHERE id= ?", args...)
|
_, err := a.meta.Exec("UPDATE export_templates SET "+strings.Join(set, ",")+" WHERE id= ?", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(w, r, http.StatusInternalServerError, err.Error())
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
|
@ -218,10 +226,23 @@ func fromJSON(b []byte) interface{} {
|
||||||
func whitelist() map[string]bool {
|
func whitelist() map[string]bool {
|
||||||
m := map[string]bool{
|
m := map[string]bool{
|
||||||
"order.order_number": true,
|
"order.order_number": true,
|
||||||
|
"order.key": true,
|
||||||
"order.creator": true,
|
"order.creator": true,
|
||||||
"order.out_trade_no": true,
|
"order.out_trade_no": true,
|
||||||
"order.type": true,
|
"order.type": true,
|
||||||
"order.status": 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.contract_price": true,
|
||||||
"order.num": true,
|
"order.num": true,
|
||||||
"order.total": true,
|
"order.total": true,
|
||||||
|
|
@ -250,6 +271,7 @@ func whitelist() map[string]bool {
|
||||||
"order_voucher.channel_activity_id": true,
|
"order_voucher.channel_activity_id": true,
|
||||||
"order_voucher.channel_voucher_id": true,
|
"order_voucher.channel_voucher_id": true,
|
||||||
"order_voucher.status": true,
|
"order_voucher.status": true,
|
||||||
|
"order_voucher.receive_mode": true,
|
||||||
"order_voucher.grant_time": true,
|
"order_voucher.grant_time": true,
|
||||||
"order_voucher.usage_time": true,
|
"order_voucher.usage_time": true,
|
||||||
"order_voucher.refund_time": true,
|
"order_voucher.refund_time": true,
|
||||||
|
|
@ -298,3 +320,100 @@ func whitelist() map[string]bool {
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fieldLabels() 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_detail.plan_title": "计划标题",
|
||||||
|
"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.channel": "渠道",
|
||||||
|
"order_cash.cash_activity_id": "红包批次号",
|
||||||
|
"order_cash.receive_status": "领取状态",
|
||||||
|
"order_cash.receive_time": "拆红包时间",
|
||||||
|
"order_cash.cash_packet_id": "红包ID",
|
||||||
|
"order_cash.cash_id": "红包规则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": "渠道立减金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": "创建时间",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package exporter
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BuildRequest struct {
|
type BuildRequest struct {
|
||||||
|
|
@ -24,15 +24,47 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
return "", nil, errors.New("field not allowed")
|
return "", nil, errors.New("field not allowed")
|
||||||
}
|
}
|
||||||
parts := strings.Split(tf, ".")
|
parts := strings.Split(tf, ".")
|
||||||
if len(parts) != 2 { return "", nil, errors.New("invalid field format") }
|
if len(parts) != 2 {
|
||||||
|
return "", nil, errors.New("invalid field format")
|
||||||
|
}
|
||||||
t, f := parts[0], parts[1]
|
t, f := parts[0], parts[1]
|
||||||
need[t] = true
|
need[t] = true
|
||||||
if t == "order" {
|
if t == "order" {
|
||||||
|
if f == "status" {
|
||||||
|
cols = append(cols, "CASE `order`.type " +
|
||||||
|
"WHEN 1 THEN (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) " +
|
||||||
|
"WHEN 2 THEN (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) " +
|
||||||
|
"WHEN 3 THEN (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) " +
|
||||||
|
"ELSE (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) END AS status")
|
||||||
|
} else if f == "type" {
|
||||||
|
cols = append(cols, "CASE `order`.type WHEN 1 THEN '直充卡密' WHEN 2 THEN '立减金' WHEN 3 THEN '红包' ELSE '' END AS type")
|
||||||
|
} else if f == "type" {
|
||||||
|
cols = append(cols, "CASE `order`.type WHEN 1 THEN '直充卡密' WHEN 2 THEN '立减金' WHEN 3 THEN '红包' ELSE '' END AS type")
|
||||||
|
} else if f == "pay_type" {
|
||||||
|
cols = append(cols, "CASE `order`.pay_type WHEN 1 THEN '支付宝' WHEN 5 THEN '微信' ELSE '' END AS pay_type")
|
||||||
|
} else if f == "pay_status" {
|
||||||
|
cols = append(cols, "CASE `order`.pay_status WHEN 1 THEN '待支付' WHEN 2 THEN '已支付' WHEN 3 THEN '已退款' ELSE '' END AS pay_status")
|
||||||
|
} else {
|
||||||
cols = append(cols, "`order`."+escape(f))
|
cols = append(cols, "`order`."+escape(f))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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 receive_status")
|
||||||
|
} else 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 channel")
|
||||||
|
} else if t == "order_voucher" && f == "channel" {
|
||||||
|
cols = append(cols, "CASE `order_voucher`.channel WHEN 1 THEN '支付宝' WHEN 2 THEN '微信' ELSE '' END AS channel")
|
||||||
|
} else 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 status")
|
||||||
|
} else 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 receive_mode")
|
||||||
|
} else if t == "order_voucher" && f == "out_biz_no" {
|
||||||
|
cols = append(cols, "'' AS out_biz_no")
|
||||||
} else {
|
} else {
|
||||||
cols = append(cols, "`"+t+"`."+escape(f))
|
cols = append(cols, "`"+t+"`."+escape(f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(cols) == 0 {
|
if len(cols) == 0 {
|
||||||
return "", nil, errors.New("no fields")
|
return "", nil, errors.New("no fields")
|
||||||
}
|
}
|
||||||
|
|
@ -42,46 +74,76 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
sb.WriteString(" FROM `order`")
|
sb.WriteString(" FROM `order`")
|
||||||
// JOINs based on need
|
// JOINs based on need
|
||||||
// order_detail
|
// order_detail
|
||||||
if need["order_detail"] { sb.WriteString(" LEFT JOIN `order_detail` ON `order_detail`.order_number = `order`.order_number") }
|
if need["order_detail"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `order_detail` ON `order_detail`.order_number = `order`.order_number")
|
||||||
|
}
|
||||||
// order_cash
|
// order_cash
|
||||||
if need["order_cash"] { sb.WriteString(" LEFT JOIN `order_cash` ON `order_cash`.order_number = `order`.order_number") }
|
if need["order_cash"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `order_cash` ON `order_cash`.order_number = `order`.order_number")
|
||||||
|
}
|
||||||
// order_voucher
|
// order_voucher
|
||||||
if need["order_voucher"] { sb.WriteString(" LEFT JOIN `order_voucher` ON `order_voucher`.order_number = `order`.order_number") }
|
if need["order_voucher"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `order_voucher` ON `order_voucher`.order_number = `order`.order_number")
|
||||||
|
}
|
||||||
// plan
|
// plan
|
||||||
if need["plan"] || need["key_batch"] { sb.WriteString(" LEFT JOIN `plan` ON `plan`.id = `order`.plan_id") }
|
if need["plan"] || need["key_batch"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `plan` ON `plan`.id = `order`.plan_id")
|
||||||
|
}
|
||||||
// key_batch depends on plan
|
// key_batch depends on plan
|
||||||
if need["key_batch"] { sb.WriteString(" LEFT JOIN `key_batch` ON `key_batch`.plan_id = `plan`.id") }
|
if need["key_batch"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `key_batch` ON `key_batch`.plan_id = `plan`.id")
|
||||||
|
}
|
||||||
// code_batch depends on key_batch
|
// code_batch depends on key_batch
|
||||||
if need["code_batch"] { sb.WriteString(" LEFT JOIN `code_batch` ON `code_batch`.key_batch_id = `key_batch`.id") }
|
if need["code_batch"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `code_batch` ON `code_batch`.key_batch_id = `key_batch`.id")
|
||||||
|
}
|
||||||
// voucher depends on order_voucher
|
// voucher depends on order_voucher
|
||||||
if need["voucher"] { sb.WriteString(" LEFT JOIN `voucher` ON `voucher`.channel_activity_id = `order_voucher`.channel_activity_id") }
|
if need["voucher"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `voucher` ON `voucher`.channel_activity_id = `order_voucher`.channel_activity_id")
|
||||||
|
}
|
||||||
// voucher_batch depends on voucher
|
// voucher_batch depends on voucher
|
||||||
if need["voucher_batch"] { sb.WriteString(" LEFT JOIN `voucher_batch` ON `voucher_batch`.voucher_id = `voucher`.id") }
|
if need["voucher_batch"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `voucher_batch` ON `voucher_batch`.voucher_id = `voucher`.id")
|
||||||
|
}
|
||||||
// merchant_key_send depends on order.key
|
// merchant_key_send depends on order.key
|
||||||
if need["merchant_key_send"] { sb.WriteString(" LEFT JOIN `merchant_key_send` ON `order`." + escape("key") + " = `merchant_key_send`.key") }
|
if need["merchant_key_send"] {
|
||||||
|
sb.WriteString(" LEFT JOIN `merchant_key_send` ON `order`." + escape("key") + " = `merchant_key_send`.key")
|
||||||
|
}
|
||||||
|
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
where := []string{}
|
where := []string{}
|
||||||
// collect need from filters referencing related tables
|
// 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_cash_cash_activity_id_eq"]; ok {
|
||||||
if _, ok := req.Filters["order_voucher_channel_activity_id_eq"]; ok { need["order_voucher"] = true }
|
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 {
|
if _, ok := req.Filters["voucher_batch_channel_activity_id_eq"]; ok {
|
||||||
need["voucher_batch"] = true
|
need["voucher_batch"] = true
|
||||||
need["voucher"] = true
|
need["voucher"] = true
|
||||||
need["order_voucher"] = true
|
need["order_voucher"] = true
|
||||||
}
|
}
|
||||||
if _, ok := req.Filters["merchant_out_biz_no_eq"]; ok { need["merchant_key_send"] = true }
|
if _, ok := req.Filters["merchant_out_biz_no_eq"]; ok {
|
||||||
|
need["merchant_key_send"] = true
|
||||||
|
}
|
||||||
if v, ok := req.Filters["creator_in"]; ok {
|
if v, ok := req.Filters["creator_in"]; ok {
|
||||||
ids := []interface{}{}
|
ids := []interface{}{}
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
ids = t
|
ids = t
|
||||||
case []int:
|
case []int:
|
||||||
for _, x := range t { ids = append(ids, x) }
|
for _, x := range t {
|
||||||
case []string:
|
ids = append(ids, x)
|
||||||
for _, x := range t { ids = append(ids, x) }
|
}
|
||||||
|
case []string:
|
||||||
|
for _, x := range t {
|
||||||
|
ids = append(ids, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return "", nil, errors.New("creator_in required")
|
||||||
}
|
}
|
||||||
if len(ids) == 0 { return "", nil, errors.New("creator_in required") }
|
|
||||||
ph := strings.Repeat("?,", len(ids))
|
ph := strings.Repeat("?,", len(ids))
|
||||||
ph = strings.TrimSuffix(ph, ",")
|
ph = strings.TrimSuffix(ph, ",")
|
||||||
where = append(where, "`order`.creator IN ("+ph+")")
|
where = append(where, "`order`.creator IN ("+ph+")")
|
||||||
|
|
@ -91,7 +153,9 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
var arr []interface{}
|
var arr []interface{}
|
||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
json.Unmarshal(b, &arr)
|
json.Unmarshal(b, &arr)
|
||||||
if len(arr) != 2 { return "", nil, errors.New("create_time_between requires 2 values") }
|
if len(arr) != 2 {
|
||||||
|
return "", nil, errors.New("create_time_between requires 2 values")
|
||||||
|
}
|
||||||
where = append(where, "`order`.create_time BETWEEN ? AND ?")
|
where = append(where, "`order`.create_time BETWEEN ? AND ?")
|
||||||
args = append(args, arr[0], arr[1])
|
args = append(args, arr[0], arr[1])
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +168,13 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
tv = t
|
tv = t
|
||||||
case string:
|
case string:
|
||||||
// simple digits parsing
|
// simple digits parsing
|
||||||
for i := 0; i < len(t); i++ { c := t[i]; if c<'0'||c>'9' { continue }; tv = tv*10 + int(c-'0') }
|
for i := 0; i < len(t); i++ {
|
||||||
|
c := t[i]
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tv = tv*10 + int(c-'0')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if tv == 1 || tv == 2 || tv == 3 {
|
if tv == 1 || tv == 2 || tv == 3 {
|
||||||
where = append(where, "`order`.type = ?")
|
where = append(where, "`order`.type = ?")
|
||||||
|
|
@ -113,47 +183,80 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["out_trade_no_eq"]; ok {
|
if v, ok := req.Filters["out_trade_no_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.out_trade_no = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.out_trade_no = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["account_eq"]; ok {
|
if v, ok := req.Filters["account_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.account = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.account = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["plan_id_eq"]; ok {
|
if v, ok := req.Filters["plan_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.plan_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.plan_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["key_batch_id_eq"]; ok {
|
if v, ok := req.Filters["key_batch_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.key_batch_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.key_batch_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["product_id_eq"]; ok {
|
if v, ok := req.Filters["product_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.product_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.product_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["reseller_id_eq"]; ok {
|
if v, ok := req.Filters["reseller_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.reseller_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.reseller_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["code_batch_id_eq"]; ok {
|
if v, ok := req.Filters["code_batch_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order`.code_batch_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order`.code_batch_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["order_cash_cash_activity_id_eq"]; ok {
|
if v, ok := req.Filters["order_cash_cash_activity_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order_cash`.cash_activity_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order_cash`.cash_activity_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["order_voucher_channel_activity_id_eq"]; ok {
|
if v, ok := req.Filters["order_voucher_channel_activity_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`order_voucher`.channel_activity_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`order_voucher`.channel_activity_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["voucher_batch_channel_activity_id_eq"]; ok {
|
if v, ok := req.Filters["voucher_batch_channel_activity_id_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`voucher_batch`.channel_activity_id = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`voucher_batch`.channel_activity_id = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := req.Filters["merchant_out_biz_no_eq"]; ok {
|
if v, ok := req.Filters["merchant_out_biz_no_eq"]; ok {
|
||||||
s := toString(v)
|
s := toString(v)
|
||||||
if s != "" { where = append(where, "`merchant_key_send`.out_biz_no = ?"); args = append(args, s) }
|
if s != "" {
|
||||||
|
where = append(where, "`merchant_key_send`.out_biz_no = ?")
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
sb.WriteString(" WHERE ")
|
sb.WriteString(" WHERE ")
|
||||||
|
|
@ -163,7 +266,9 @@ func BuildSQL(req BuildRequest, whitelist map[string]bool) (string, []interface{
|
||||||
}
|
}
|
||||||
|
|
||||||
func escape(s string) string {
|
func escape(s string) string {
|
||||||
if s == "key" { return "`key`" }
|
if s == "key" {
|
||||||
|
return "`key`"
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,16 @@ func NewXLSXWriter(dir, name, sheet string) (*XLSXWriter, string, error) {
|
||||||
os.MkdirAll(dir, 0755)
|
os.MkdirAll(dir, 0755)
|
||||||
p := filepath.Join(dir, name+"_"+time.Now().Format("20060102150405")+".xlsx")
|
p := filepath.Join(dir, name+"_"+time.Now().Format("20060102150405")+".xlsx")
|
||||||
f := excelize.NewFile()
|
f := excelize.NewFile()
|
||||||
f.NewSheet(sheet)
|
idx, err := f.GetSheetIndex(sheet)
|
||||||
idx, _ := f.GetSheetIndex(sheet)
|
if err != nil || idx < 0 {
|
||||||
|
idx, _ = f.NewSheet(sheet)
|
||||||
f.SetActiveSheet(idx)
|
f.SetActiveSheet(idx)
|
||||||
|
if sheet != "Sheet1" {
|
||||||
|
_ = f.DeleteSheet("Sheet1")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.SetActiveSheet(idx)
|
||||||
|
}
|
||||||
return &XLSXWriter{f: f, sheet: sheet, row: 1}, p, nil
|
return &XLSXWriter{f: f, sheet: sheet, row: 1}, p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +115,7 @@ func col(n int) string {
|
||||||
s := ""
|
s := ""
|
||||||
for n > 0 {
|
for n > 0 {
|
||||||
n--
|
n--
|
||||||
s = string('A'+(n%26)) + s
|
s = string(rune('A'+(n%26))) + s
|
||||||
n /= 26
|
n /= 26
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ func Apply(db *sql.DB) error {
|
||||||
"ALTER TABLE export_jobs ADD COLUMN explain_json JSON",
|
"ALTER TABLE export_jobs ADD COLUMN explain_json JSON",
|
||||||
"ALTER TABLE export_jobs ADD COLUMN explain_score INT",
|
"ALTER TABLE export_jobs ADD COLUMN explain_score INT",
|
||||||
"ALTER TABLE export_jobs ADD COLUMN filters_json JSON",
|
"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 {
|
for _, s := range optional {
|
||||||
if _, err := db.Exec(s); err != nil {
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
BIN
server/server
BIN
server/server
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
订单编号,创建者ID,支付流水号,订单类型,订单状态,合同单价,数量,总金额,支付金额,创建时间,更新时间,计划标题,分销商名称,商品名称,商品图片URL,官方价,成本价,创建时间,更新时间,计划ID,计划标题,状态,开始时间,结束时间,批次ID,批次名称,绑定对象,发放数量,剩余库存,开始时间,结束时间,兑换批次ID,标题,状态,开始时间,结束时间,数量,使用数,库存,商户ID,商户业务号,券码,状态,核销时间,创建时间
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -48,29 +48,44 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-card v-if="jobsVisible" :header="'导出任务(模板 '+ (jobsTplId||'') +')'">
|
<el-dialog v-model="jobsVisible" :title="'导出任务(模板 '+ (jobsTplId||'') +')'" width="1000px">
|
||||||
<el-table :data="jobs" size="small" stripe>
|
<el-table :data="jobs" size="small" stripe row-key="id">
|
||||||
<el-table-column prop="id" label="ID"></el-table-column>
|
<el-table-column prop="id" label="ID"></el-table-column>
|
||||||
<el-table-column prop="eval_status" label="校验状态"></el-table-column>
|
<el-table-column label="校验状态">
|
||||||
|
<template #default="scope">{{ scope.row.eval_status || '评估中' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="进度">
|
<el-table-column label="进度">
|
||||||
<template #default="scope">{{ jobPercent(scope.row) }}</template>
|
<template #default="scope">{{ jobPercent(scope.row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="eval_desc" label="评估描述"></el-table-column>
|
|
||||||
<el-table-column prop="total_rows" label="行数"></el-table-column>
|
<el-table-column prop="row_estimate" label="行数"></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 prop="file_format" label="格式"></el-table-column>
|
||||||
<el-table-column prop="created_at" label="创建时间"></el-table-column>
|
<el-table-column label="创建时间">
|
||||||
<el-table-column label="操作" width="140">
|
<template #default="scope">{{ fmtDT(new Date(scope.row.created_at)) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button size="small" type="success" @click="download(scope.row.id)">下载</el-button>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div style="display:flex;justify-content:space-between;margin-top:8px">
|
|
||||||
<div><el-button size="small" @click="closeJobs">关闭</el-button></div>
|
<div style="display:flex;justify-content:flex-end;margin-top:8px">
|
||||||
<div><el-pagination background layout="prev, pager, next, total" :total="jobsTotal" :page-size="jobsPageSize" :current-page="jobsPage" @current-change="(p)=>loadJobs(p)" /></div>
|
<el-pagination background layout="prev, pager, next, total" :total="jobsTotal" :page-size="jobsPageSize" v-model:currentPage="jobsPage" @current-change="loadJobs" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!jobs || !jobs.length" style="padding:8px 0;color:#999">暂无任务</div>
|
<template #footer>
|
||||||
</el-card>
|
<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>
|
||||||
|
<el-button @click="sqlVisible=false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-main>
|
</el-main>
|
||||||
|
|
@ -96,15 +111,20 @@
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="字段选择" required show-message prop="fieldsSel">
|
<el-form-item label="字段选择" required show-message prop="fieldsSel">
|
||||||
|
<div ref="createCascaderRoot">
|
||||||
<el-cascader
|
<el-cascader
|
||||||
ref="fieldsCascader"
|
ref="fieldsCascader"
|
||||||
v-model="form.fieldsSel"
|
v-model="form.fieldsSel"
|
||||||
:options="fieldOptions"
|
:options="fieldOptions"
|
||||||
:props="{ multiple: true, checkStrictly: false, expandTrigger: 'hover', checkOnClickNode: true, checkOnClickLeaf: true }"
|
:props="{ multiple: true, checkStrictly: false, expandTrigger: 'hover', checkOnClickNode: true, checkOnClickLeaf: true }"
|
||||||
|
:teleported="false"
|
||||||
collapse-tags
|
collapse-tags
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
placeholder="按场景逐级选择,可多选"
|
placeholder="按场景逐级选择,可多选"
|
||||||
|
@visible-change="onCascaderVisible('create', $event)"
|
||||||
|
@change="onFieldsSelChange('create')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="数据权限" required show-message prop="permissionMode">
|
<el-form-item label="数据权限" required show-message prop="permissionMode">
|
||||||
<el-select v-model="form.permissionMode" style="width:160px">
|
<el-select v-model="form.permissionMode" style="width:160px">
|
||||||
|
|
@ -141,17 +161,56 @@
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="editVisible" title="编辑模板" :width="editWidth">
|
<el-dialog v-model="editVisible" title="编辑模板" :width="editWidth">
|
||||||
<el-form ref="editFormRef" :model="edit" :rules="editRules" label-width="110px">
|
<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-form-item>
|
<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 label="订单数据" value="order" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="订单类型" required show-message prop="orderType">
|
||||||
|
<el-radio-group v-model="edit.orderType">
|
||||||
|
<el-radio :label="1">直充卡密</el-radio>
|
||||||
|
<el-radio :label="2">立减金</el-radio>
|
||||||
|
<el-radio :label="3">红包</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"
|
||||||
|
:options="editFieldOptions"
|
||||||
|
: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-form-item label="输出格式">
|
||||||
<el-select v-model="edit.file_format" :teleported="false" placeholder="请选择" style="width:160px">
|
<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-option v-for="opt in formatOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
<el-form-item label="可见性">
|
<el-form-item label="可见性">
|
||||||
<el-select v-model="edit.visibility" clearable :teleported="false" style="width:160px" placeholder="请选择">
|
<el-select v-model="edit.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-option v-for="opt in visibilityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="resizeDialog('edit', -100)">缩小</el-button>
|
<el-button @click="resizeDialog('edit', -100)">缩小</el-button>
|
||||||
|
|
|
||||||
234
web/main.js
234
web/main.js
|
|
@ -7,8 +7,10 @@ const { createApp, reactive } = Vue;
|
||||||
jobsVisible: false,
|
jobsVisible: false,
|
||||||
jobsTplId: null,
|
jobsTplId: null,
|
||||||
jobsPage: 1,
|
jobsPage: 1,
|
||||||
jobsPageSize: 15,
|
jobsPageSize: 10,
|
||||||
jobsTotal: 0,
|
jobsTotal: 0,
|
||||||
|
sqlVisible: false,
|
||||||
|
sqlText: '',
|
||||||
job: {},
|
job: {},
|
||||||
form: {
|
form: {
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -27,8 +29,8 @@ const { createApp, reactive } = Vue;
|
||||||
editVisible: false,
|
editVisible: false,
|
||||||
exportVisible: false,
|
exportVisible: false,
|
||||||
createWidth: (localStorage.getItem('tplDialogWidth') || '900px'),
|
createWidth: (localStorage.getItem('tplDialogWidth') || '900px'),
|
||||||
editWidth: (localStorage.getItem('tplEditDialogWidth') || '600px'),
|
editWidth: (localStorage.getItem('tplEditDialogWidth') || '900px'),
|
||||||
edit: { id: null, name: '', visibility: 'private', file_format: 'csv' },
|
edit: { id: null, name: '', datasource: 'marketing', main_table: 'order', orderType: 1, fieldsSel: [], visibility: 'private', file_format: 'xlsx' },
|
||||||
exportForm: { tplId: null, datasource: 'marketing', file_format: 'xlsx', dateRange: [], creatorIds: [], creatorIdsRaw: '', resellerId: null, planId: null, keyBatchId: null, codeBatchId: null, productId: null, outTradeNo: '', account: '', cashActivityId: '', voucherChannelActivityId: '', voucherBatchChannelActivityId: '', outBizNo: '' },
|
exportForm: { tplId: null, datasource: 'marketing', file_format: 'xlsx', dateRange: [], creatorIds: [], creatorIdsRaw: '', resellerId: null, planId: null, keyBatchId: null, codeBatchId: null, productId: null, outTradeNo: '', account: '', cashActivityId: '', voucherChannelActivityId: '', voucherBatchChannelActivityId: '', outBizNo: '' },
|
||||||
exportTpl: { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' }
|
exportTpl: { id: null, filters: {}, main_table: '', fields: [], datasource: '', file_format: '' }
|
||||||
})
|
})
|
||||||
|
|
@ -38,14 +40,27 @@ const { createApp, reactive } = Vue;
|
||||||
marketing: {
|
marketing: {
|
||||||
order: [
|
order: [
|
||||||
{ value: 'order_number', label: '订单编号' },
|
{ value: 'order_number', label: '订单编号' },
|
||||||
|
{ value: 'key', label: 'KEY' },
|
||||||
{ value: 'creator', label: '创建者ID' },
|
{ value: 'creator', label: '创建者ID' },
|
||||||
{ value: 'out_trade_no', label: '支付流水号' },
|
{ value: 'out_trade_no', label: '支付流水号' },
|
||||||
{ value: 'type', label: '订单类型' },
|
{ value: 'type', label: '订单类型' },
|
||||||
{ value: 'status', label: '订单状态' },
|
{ value: 'status', label: '订单状态' },
|
||||||
|
{ value: 'account', label: '账号' },
|
||||||
|
{ value: 'product_id', label: '商品ID' },
|
||||||
|
{ value: 'reseller_id', label: '分销商ID' },
|
||||||
|
{ value: 'plan_id', label: '计划ID' },
|
||||||
|
{ value: 'key_batch_id', label: 'KEY批次ID' },
|
||||||
|
{ value: 'code_batch_id', label: '兑换批次ID' },
|
||||||
{ value: 'contract_price', label: '合同单价' },
|
{ value: 'contract_price', label: '合同单价' },
|
||||||
{ value: 'num', label: '数量' },
|
{ value: 'num', label: '数量' },
|
||||||
{ value: 'total', label: '总金额' },
|
{ value: 'total', label: '总金额' },
|
||||||
{ value: 'pay_amount', label: '支付金额' },
|
{ value: 'pay_amount', label: '支付金额' },
|
||||||
|
{ value: 'pay_type', label: '支付方式' },
|
||||||
|
{ value: 'pay_status', label: '支付状态' },
|
||||||
|
{ value: 'use_coupon', label: '是否使用优惠券' },
|
||||||
|
{ value: 'deliver_status', label: '投递状态' },
|
||||||
|
{ value: 'expire_time', label: '过期处理时间' },
|
||||||
|
{ value: 'recharge_time', label: '充值时间' },
|
||||||
{ value: 'create_time', label: '创建时间' },
|
{ value: 'create_time', label: '创建时间' },
|
||||||
{ value: 'update_time', label: '更新时间' }
|
{ value: 'update_time', label: '更新时间' }
|
||||||
]
|
]
|
||||||
|
|
@ -77,6 +92,7 @@ const { createApp, reactive } = Vue;
|
||||||
{ value: 'channel_activity_id', label: '渠道立减金批次' },
|
{ value: 'channel_activity_id', label: '渠道立减金批次' },
|
||||||
{ value: 'channel_voucher_id', label: '渠道立减金ID' },
|
{ value: 'channel_voucher_id', label: '渠道立减金ID' },
|
||||||
{ value: 'status', label: '状态' },
|
{ value: 'status', label: '状态' },
|
||||||
|
{ value: 'receive_mode', label: '领取方式' },
|
||||||
{ value: 'grant_time', label: '领取时间' },
|
{ value: 'grant_time', label: '领取时间' },
|
||||||
{ value: 'usage_time', label: '核销时间' },
|
{ value: 'usage_time', label: '核销时间' },
|
||||||
{ value: 'refund_time', label: '退款时间' },
|
{ value: 'refund_time', label: '退款时间' },
|
||||||
|
|
@ -139,14 +155,27 @@ const { createApp, reactive } = Vue;
|
||||||
ymt: {
|
ymt: {
|
||||||
order: [
|
order: [
|
||||||
{ value: 'order_number', label: '订单编号' },
|
{ value: 'order_number', label: '订单编号' },
|
||||||
|
{ value: 'key', label: 'KEY' },
|
||||||
{ value: 'creator', label: '创建者ID' },
|
{ value: 'creator', label: '创建者ID' },
|
||||||
{ value: 'out_trade_no', label: '支付流水号' },
|
{ value: 'out_trade_no', label: '支付流水号' },
|
||||||
{ value: 'type', label: '订单类型' },
|
{ value: 'type', label: '订单类型' },
|
||||||
{ value: 'status', label: '订单状态' },
|
{ value: 'status', label: '订单状态' },
|
||||||
|
{ value: 'account', label: '账号' },
|
||||||
|
{ value: 'product_id', label: '商品ID' },
|
||||||
|
{ value: 'reseller_id', label: '分销商ID' },
|
||||||
|
{ value: 'plan_id', label: '计划ID' },
|
||||||
|
{ value: 'key_batch_id', label: 'KEY批次ID' },
|
||||||
|
{ value: 'code_batch_id', label: '兑换批次ID' },
|
||||||
{ value: 'contract_price', label: '合同单价' },
|
{ value: 'contract_price', label: '合同单价' },
|
||||||
{ value: 'num', label: '数量' },
|
{ value: 'num', label: '数量' },
|
||||||
{ value: 'total', label: '总金额' },
|
{ value: 'total', label: '总金额' },
|
||||||
{ value: 'pay_amount', label: '支付金额' },
|
{ value: 'pay_amount', label: '支付金额' },
|
||||||
|
{ value: 'pay_type', label: '支付方式' },
|
||||||
|
{ value: 'pay_status', label: '支付状态' },
|
||||||
|
{ value: 'use_coupon', label: '是否使用优惠券' },
|
||||||
|
{ value: 'deliver_status', label: '投递状态' },
|
||||||
|
{ value: 'expire_time', label: '过期处理时间' },
|
||||||
|
{ value: 'recharge_time', label: '充值时间' },
|
||||||
{ value: 'create_time', label: '创建时间' },
|
{ value: 'create_time', label: '创建时间' },
|
||||||
{ value: 'update_time', label: '更新时间' }
|
{ value: 'update_time', label: '更新时间' }
|
||||||
],
|
],
|
||||||
|
|
@ -177,6 +206,7 @@ const { createApp, reactive } = Vue;
|
||||||
{ value: 'channel_activity_id', label: '渠道立减金批次' },
|
{ value: 'channel_activity_id', label: '渠道立减金批次' },
|
||||||
{ value: 'channel_voucher_id', label: '渠道立减金ID' },
|
{ value: 'channel_voucher_id', label: '渠道立减金ID' },
|
||||||
{ value: 'status', label: '状态' },
|
{ value: 'status', label: '状态' },
|
||||||
|
{ value: 'receive_mode', label: '领取方式' },
|
||||||
{ value: 'grant_time', label: '领取时间' },
|
{ value: 'grant_time', label: '领取时间' },
|
||||||
{ value: 'usage_time', label: '核销时间' },
|
{ value: 'usage_time', label: '核销时间' },
|
||||||
{ value: 'refund_time', label: '退款时间' },
|
{ value: 'refund_time', label: '退款时间' },
|
||||||
|
|
@ -315,6 +345,44 @@ const { createApp, reactive } = Vue;
|
||||||
const createFormRef = Vue.ref(null)
|
const createFormRef = Vue.ref(null)
|
||||||
const exportFormRef = Vue.ref(null)
|
const exportFormRef = Vue.ref(null)
|
||||||
const editFormRef = Vue.ref(null)
|
const editFormRef = Vue.ref(null)
|
||||||
|
const fieldsCascader = Vue.ref(null)
|
||||||
|
const editFieldsCascader = Vue.ref(null)
|
||||||
|
const createCascaderRoot = Vue.ref(null)
|
||||||
|
const editCascaderRoot = Vue.ref(null)
|
||||||
|
const cascaderScroll = { create: [], edit: [] }
|
||||||
|
const getWraps = (kind)=>{
|
||||||
|
const r = kind==='create' ? createCascaderRoot.value : editCascaderRoot.value
|
||||||
|
const el = r && r.$el ? r.$el : r
|
||||||
|
if(!el || typeof el.querySelectorAll !== 'function') return []
|
||||||
|
return Array.from(el.querySelectorAll('.el-cascader__panel .el-scrollbar__wrap'))
|
||||||
|
}
|
||||||
|
const onCascaderVisible = (kind, v)=>{
|
||||||
|
if(!v) return
|
||||||
|
Vue.nextTick(()=>{
|
||||||
|
const wraps = getWraps(kind)
|
||||||
|
cascaderScroll[kind] = wraps.map(w=>w.scrollTop)
|
||||||
|
wraps.forEach((wrap, idx)=>{
|
||||||
|
wrap.addEventListener('scroll', (e)=>{ cascaderScroll[kind][idx] = e.target.scrollTop }, { passive: true })
|
||||||
|
})
|
||||||
|
const r = kind==='create' ? createCascaderRoot.value : editCascaderRoot.value
|
||||||
|
const el = r && r.$el ? r.$el : r
|
||||||
|
if(el && typeof el.querySelectorAll === 'function'){
|
||||||
|
el.querySelectorAll('.el-cascader-node').forEach(node=>{
|
||||||
|
node.addEventListener('click', ()=>{
|
||||||
|
const ws = getWraps(kind)
|
||||||
|
cascaderScroll[kind] = ws.map(w=>w.scrollTop)
|
||||||
|
}, { passive: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onFieldsSelChange = (kind)=>{
|
||||||
|
const tops = cascaderScroll[kind] || []
|
||||||
|
Vue.nextTick(()=>{
|
||||||
|
const wraps = getWraps(kind)
|
||||||
|
wraps.forEach((w, idx)=>{ w.scrollTop = tops[idx] || 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
const createRules = {
|
const createRules = {
|
||||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||||
datasource: [{ required: true, message: '请选择数据源', trigger: 'change' }],
|
datasource: [{ required: true, message: '请选择数据源', trigger: 'change' }],
|
||||||
|
|
@ -327,12 +395,57 @@ const { createApp, reactive } = Vue;
|
||||||
visibility: [{ required: true, message: '请选择可见性', trigger: 'change' }]
|
visibility: [{ required: true, message: '请选择可见性', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
const editRules = {
|
const editRules = {
|
||||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }]
|
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||||
|
orderType: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
||||||
|
fieldsSel: [{ validator: (_rule, val, cb)=>{ if(Array.isArray(val) && val.length>0){ cb() } else { cb(new Error('请至少选择一个字段')) } }, trigger: 'change' }]
|
||||||
}
|
}
|
||||||
const exportRules = {
|
const exportRules = {
|
||||||
tplId: [{ required: true, message: '请选择模板', trigger: 'change' }],
|
tplId: [{ required: true, message: '请选择模板', trigger: 'change' }],
|
||||||
dateRange: [{ validator: (_r, v, cb)=>{ if(Array.isArray(v) && v.length===2){ cb() } else { cb(new Error('请选择时间范围')) } }, trigger: 'change' }]
|
dateRange: [{ validator: (_r, v, cb)=>{ if(Array.isArray(v) && v.length===2){ cb() } else { cb(new Error('请选择时间范围')) } }, trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
const editFieldOptions = Vue.computed(()=>{
|
||||||
|
const ds = state.edit.datasource
|
||||||
|
const FM = FIELDS_MAP[ds] || {}
|
||||||
|
const node = (table, children=[])=>({ value: table, label: TABLE_LABELS[table]||table, children })
|
||||||
|
const fieldsNode = (table)=> (FM[table]||[])
|
||||||
|
const orderChildrenBase = []
|
||||||
|
orderChildrenBase.push(...fieldsNode('order'))
|
||||||
|
orderChildrenBase.push(node('order_detail', fieldsNode('order_detail')))
|
||||||
|
const planChildren = []
|
||||||
|
planChildren.push(...fieldsNode('plan'))
|
||||||
|
planChildren.push(node('key_batch', [
|
||||||
|
...fieldsNode('key_batch'),
|
||||||
|
node('code_batch', fieldsNode('code_batch'))
|
||||||
|
]))
|
||||||
|
const voucherChildren = []
|
||||||
|
voucherChildren.push(...fieldsNode('order_voucher'))
|
||||||
|
voucherChildren.push(node('voucher', [
|
||||||
|
...fieldsNode('voucher'),
|
||||||
|
node('voucher_batch', fieldsNode('voucher_batch'))
|
||||||
|
]))
|
||||||
|
const orderChildrenFor = (type)=>{
|
||||||
|
const ch = [...orderChildrenBase]
|
||||||
|
if(type===1){
|
||||||
|
ch.push(node('plan', planChildren))
|
||||||
|
ch.push(node('merchant_key_send', fieldsNode('merchant_key_send')))
|
||||||
|
} else if(type===2){
|
||||||
|
ch.push(node('order_voucher', voucherChildren))
|
||||||
|
ch.push(node('plan', planChildren))
|
||||||
|
} else if(type===3){
|
||||||
|
ch.push(node('order_cash', fieldsNode('order_cash')))
|
||||||
|
ch.push(node('plan', planChildren))
|
||||||
|
} else {
|
||||||
|
ch.push(node('order_cash', fieldsNode('order_cash')))
|
||||||
|
ch.push(node('order_voucher', voucherChildren))
|
||||||
|
ch.push(node('plan', planChildren))
|
||||||
|
ch.push(node('merchant_key_send', fieldsNode('merchant_key_send')))
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
const type = Number(state.edit.orderType || 0)
|
||||||
|
const orderNode = node('order', orderChildrenFor(type))
|
||||||
|
return [ orderNode ]
|
||||||
|
})
|
||||||
const visibilityOptions = [
|
const visibilityOptions = [
|
||||||
{ label: '个人', value: 'private' },
|
{ label: '个人', value: 'private' },
|
||||||
{ label: '公共', value: 'public' }
|
{ label: '公共', value: 'public' }
|
||||||
|
|
@ -460,15 +573,27 @@ const { createApp, reactive } = Vue;
|
||||||
state.jobsPage = Number(payload.page || page)
|
state.jobsPage = Number(payload.page || page)
|
||||||
}catch(_e){ state.jobs = [] }
|
}catch(_e){ state.jobs = [] }
|
||||||
}
|
}
|
||||||
const openJobs = (row)=>{ state.jobsTplId = row.id; state.jobsVisible = true; loadJobs(1) }
|
let jobsPollTimer = null
|
||||||
const closeJobs = ()=>{ state.jobsVisible = false }
|
const startJobsPolling = ()=>{
|
||||||
|
if(jobsPollTimer) return
|
||||||
|
jobsPollTimer = setInterval(()=>{ if(state.jobsVisible){ loadJobs(state.jobsPage) } }, 1000)
|
||||||
|
}
|
||||||
|
const stopJobsPolling = ()=>{ if(jobsPollTimer){ clearInterval(jobsPollTimer); jobsPollTimer=null } }
|
||||||
|
const openJobs = (row)=>{ state.jobsTplId = row.id; state.jobsVisible = true; loadJobs(1); startJobsPolling() }
|
||||||
|
const closeJobs = ()=>{ state.jobsVisible = false; stopJobsPolling() }
|
||||||
const jobPercent = (row)=>{
|
const jobPercent = (row)=>{
|
||||||
const est = Number(row.row_estimate || 0)
|
const est = Number(row.row_estimate || 0)
|
||||||
const done = Number(row.total_rows || 0)
|
const done = Number(row.total_rows || 0)
|
||||||
if(row.status==='completed') return '100%'
|
if(row.status==='completed') return '100%'
|
||||||
|
if(row.status==='failed') return '失败'
|
||||||
|
if(row.status==='canceled') return '已取消'
|
||||||
if(row.status==='queued') return '0%'
|
if(row.status==='queued') return '0%'
|
||||||
if(est>0 && done>=0){ const p = Math.max(0, Math.min(100, Math.floor(done*100/est))); return p + '%' }
|
if(row.status==='running'){
|
||||||
return row.status || ''
|
if(est>0){ const p = Math.max(0, Math.min(100, Math.floor(done*100/est))); return p + '%' }
|
||||||
|
return '0%'
|
||||||
|
}
|
||||||
|
if(est>0){ const p = Math.max(0, Math.min(100, Math.floor(done*100/est))); return p + '%' }
|
||||||
|
return '评估中'
|
||||||
}
|
}
|
||||||
const createTemplate = async ()=>{
|
const createTemplate = async ()=>{
|
||||||
const formRef = createFormRef.value
|
const formRef = createFormRef.value
|
||||||
|
|
@ -531,7 +656,8 @@ const { createApp, reactive } = Vue;
|
||||||
if(!ok){ msg('请完善必填项','error'); return }
|
if(!ok){ msg('请完善必填项','error'); return }
|
||||||
const id = state.exportForm.tplId
|
const id = state.exportForm.tplId
|
||||||
const filters = {}
|
const filters = {}
|
||||||
// 订单类型与权限按模板配置,此处不显示不提交
|
const tVal = exportType.value
|
||||||
|
if(tVal != null){ filters.type_eq = Number(tVal) }
|
||||||
if(Array.isArray(state.exportForm.dateRange) && state.exportForm.dateRange.length===2){ filters.create_time_between = [ state.exportForm.dateRange[0], state.exportForm.dateRange[1] ] }
|
if(Array.isArray(state.exportForm.dateRange) && state.exportForm.dateRange.length===2){ filters.create_time_between = [ state.exportForm.dateRange[0], state.exportForm.dateRange[1] ] }
|
||||||
if(state.exportForm.outTradeNo){ filters.out_trade_no_eq = state.exportForm.outTradeNo }
|
if(state.exportForm.outTradeNo){ filters.out_trade_no_eq = state.exportForm.outTradeNo }
|
||||||
if(state.exportForm.account){ filters.account_eq = state.exportForm.account }
|
if(state.exportForm.account){ filters.account_eq = state.exportForm.account }
|
||||||
|
|
@ -552,7 +678,12 @@ const { createApp, reactive } = Vue;
|
||||||
const j=await r.json();
|
const j=await r.json();
|
||||||
const jid = j?.data?.id ?? j?.id
|
const jid = j?.data?.id ?? j?.id
|
||||||
state.exportVisible=false
|
state.exportVisible=false
|
||||||
if(jid){ loadJob(jid); loadJobs() } else { msg('任务创建返回异常','error') }
|
if(jid){
|
||||||
|
state.jobsTplId = Number(id)
|
||||||
|
state.jobsVisible = true
|
||||||
|
loadJobs(1)
|
||||||
|
startJobsPolling()
|
||||||
|
} else { msg('任务创建返回异常','error') }
|
||||||
}
|
}
|
||||||
Vue.watch(()=>state.exportForm.creatorIds, ()=>{ state.exportForm.resellerId=null; state.exportForm.planId=null; state.exportForm.keyBatchId=null; state.exportForm.codeBatchId=null; state.exportForm.productId=null; loadResellers() })
|
Vue.watch(()=>state.exportForm.creatorIds, ()=>{ state.exportForm.resellerId=null; state.exportForm.planId=null; state.exportForm.keyBatchId=null; state.exportForm.codeBatchId=null; state.exportForm.productId=null; loadResellers() })
|
||||||
Vue.watch(()=>state.exportForm.resellerId, ()=>{ state.exportForm.planId=null; state.exportForm.keyBatchId=null; state.exportForm.codeBatchId=null; state.exportForm.productId=null })
|
Vue.watch(()=>state.exportForm.resellerId, ()=>{ state.exportForm.planId=null; state.exportForm.keyBatchId=null; state.exportForm.codeBatchId=null; state.exportForm.productId=null })
|
||||||
|
|
@ -576,11 +707,54 @@ const { createApp, reactive } = Vue;
|
||||||
localStorage.setItem('tplEditDialogWidth', next)
|
localStorage.setItem('tplEditDialogWidth', next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const openEdit = (row)=>{
|
const openEdit = async (row)=>{
|
||||||
state.edit.id = row.id
|
state.edit.id = row.id
|
||||||
|
// 加载模板详情以便回填字段
|
||||||
|
try{
|
||||||
|
const res = await fetch(API_BASE + '/api/templates/'+row.id)
|
||||||
|
const data = await res.json()
|
||||||
|
const tpl = data?.data || {}
|
||||||
|
state.edit.name = tpl.name || row.name || ''
|
||||||
|
state.edit.datasource = tpl.datasource || row.datasource || 'marketing'
|
||||||
|
state.edit.main_table = tpl.main_table || row.main_table || 'order'
|
||||||
|
state.edit.file_format = tpl.file_format || row.file_format || 'xlsx'
|
||||||
|
state.edit.visibility = tpl.visibility || row.visibility || 'private'
|
||||||
|
const filters = tpl.filters || {}
|
||||||
|
if(filters && (filters.type_eq != null)){
|
||||||
|
state.edit.orderType = Number(filters.type_eq)
|
||||||
|
} else if(Array.isArray(filters?.type_in) && filters.type_in.length===1){
|
||||||
|
state.edit.orderType = Number(filters.type_in[0])
|
||||||
|
} else {
|
||||||
|
state.edit.orderType = 1
|
||||||
|
}
|
||||||
|
const fields = Array.isArray(tpl.fields) ? tpl.fields : []
|
||||||
|
const toPath = (tf)=>{
|
||||||
|
const parts = String(tf||'').split('.')
|
||||||
|
if(parts.length!==2) return null
|
||||||
|
const table = parts[0]
|
||||||
|
const field = parts[1]
|
||||||
|
if(table==='order') return ['order', field]
|
||||||
|
if(table==='order_detail') return ['order','order_detail',field]
|
||||||
|
if(table==='plan') return ['order','plan',field]
|
||||||
|
if(table==='key_batch') return ['order','plan','key_batch',field]
|
||||||
|
if(table==='code_batch') return ['order','plan','key_batch','code_batch',field]
|
||||||
|
if(table==='order_voucher') return ['order','order_voucher',field]
|
||||||
|
if(table==='voucher') return ['order','order_voucher','voucher',field]
|
||||||
|
if(table==='voucher_batch') return ['order','order_voucher','voucher','voucher_batch',field]
|
||||||
|
if(table==='merchant_key_send') return ['order','merchant_key_send',field]
|
||||||
|
if(table==='order_cash') return ['order','order_cash',field]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
state.edit.fieldsSel = fields.map(toPath).filter(p=>Array.isArray(p) && p.length>=2)
|
||||||
|
}catch(_e){
|
||||||
state.edit.name = row.name
|
state.edit.name = row.name
|
||||||
state.edit.visibility = row.visibility
|
state.edit.datasource = row.datasource || 'marketing'
|
||||||
state.edit.file_format = row.file_format
|
state.edit.main_table = row.main_table || 'order'
|
||||||
|
state.edit.file_format = row.file_format || 'xlsx'
|
||||||
|
state.edit.visibility = row.visibility || 'private'
|
||||||
|
state.edit.orderType = 1
|
||||||
|
state.edit.fieldsSel = []
|
||||||
|
}
|
||||||
state.editVisible = true
|
state.editVisible = true
|
||||||
}
|
}
|
||||||
const saveEdit = async ()=>{
|
const saveEdit = async ()=>{
|
||||||
|
|
@ -588,7 +762,28 @@ const { createApp, reactive } = Vue;
|
||||||
const ok = formRef ? await formRef.validate().catch(()=>false) : true
|
const ok = formRef ? await formRef.validate().catch(()=>false) : true
|
||||||
if(!ok){ msg('请完善必填项','error'); return }
|
if(!ok){ msg('请完善必填项','error'); return }
|
||||||
const id = state.edit.id
|
const id = state.edit.id
|
||||||
const payload = { name: state.edit.name, visibility: state.edit.visibility, file_format: state.edit.file_format }
|
// 构建字段与过滤
|
||||||
|
let fields = []
|
||||||
|
const ds = state.edit.datasource
|
||||||
|
if(state.edit.fieldsSel && state.edit.fieldsSel.length){
|
||||||
|
const hasOrderOnly = state.edit.fieldsSel.some(p=>Array.isArray(p) && p.length===1 && p[0]==='order')
|
||||||
|
if(hasOrderOnly){
|
||||||
|
fields = orderLeafPaths(ds).map(p=>`${p[0]}.${p[1]}`)
|
||||||
|
} else {
|
||||||
|
fields = state.edit.fieldsSel.flatMap(path=>{
|
||||||
|
if(!Array.isArray(path)) return []
|
||||||
|
if(isGroupPath(ds, path)) return []
|
||||||
|
if(path.length>=2){
|
||||||
|
const t = path[path.length-2]
|
||||||
|
const f = path[path.length-1]
|
||||||
|
return [`${t}.${f}`]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filters = { type_eq: Number(state.edit.orderType || 1) }
|
||||||
|
const payload = { name: state.edit.name, visibility: state.edit.visibility, file_format: state.edit.file_format, fields, filters }
|
||||||
const res = await fetch(API_BASE + '/api/templates/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
const res = await fetch(API_BASE + '/api/templates/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
||||||
if(res.ok){ msg('保存成功'); state.editVisible=false; loadTemplates() } else { msg(await res.text(),'error') }
|
if(res.ok){ msg('保存成功'); state.editVisible=false; loadTemplates() } else { msg(await res.text(),'error') }
|
||||||
}
|
}
|
||||||
|
|
@ -612,9 +807,18 @@ const { createApp, reactive } = Vue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const download = (id)=>{ window.open(API_BASE + '/api/exports/'+id+'/download','_blank') }
|
const download = (id)=>{ window.open(API_BASE + '/api/exports/'+id+'/download','_blank') }
|
||||||
|
const openSQL = async (id)=>{
|
||||||
|
try{
|
||||||
|
const res = await fetch(API_BASE + '/api/exports/'+id+'/sql')
|
||||||
|
const data = await res.json()
|
||||||
|
const s = data?.data?.final_sql || data?.final_sql || data?.data?.sql || data?.sql || ''
|
||||||
|
state.sqlText = s
|
||||||
|
state.sqlVisible = true
|
||||||
|
}catch(_e){ state.sqlText=''; state.sqlVisible=false; msg('加载SQL失败','error') }
|
||||||
|
}
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
|
|
||||||
return { ...Vue.toRefs(state), visibilityOptions, formatOptions, datasourceOptions, fieldOptions, loadTemplates, createTemplate, openExport, submitExport, loadJob, loadJobs, openJobs, closeJobs, download, openEdit, saveEdit, removeTemplate, resizeDialog, createRules, exportRules, editRules, createFormRef, exportFormRef, editFormRef, dsLabel, exportType, isOrder, exportTitle, creatorOptions, resellerOptions, hasCreators, hasReseller, hasPlan, hasKeyBatch, hasCodeBatch, jobPercent }
|
return { ...Vue.toRefs(state), visibilityOptions, formatOptions, datasourceOptions, fieldOptions, editFieldOptions, loadTemplates, createTemplate, openExport, submitExport, loadJob, loadJobs, openJobs, closeJobs, download, openSQL, openEdit, saveEdit, removeTemplate, resizeDialog, createRules, exportRules, editRules, createFormRef, exportFormRef, editFormRef, dsLabel, exportType, isOrder, exportTitle, creatorOptions, resellerOptions, hasCreators, hasReseller, hasPlan, hasKeyBatch, hasCodeBatch, jobPercent, fmtDT, fieldsCascader, editFieldsCascader, createCascaderRoot, editCascaderRoot, onCascaderVisible, onFieldsSelChange }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue