chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:19:22 +08:00
commit 6366859bb3
222 changed files with 47313 additions and 0 deletions

View File

@@ -0,0 +1,319 @@
package applogic
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/meta"
"aijianzhan/platform/internal/types"
"github.com/xuri/excelize/v2"
)
// ImportRows accepts .xlsx (preferred) or .csv.
func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*types.ImportResp, error) {
tenantID := authx.TenantID(l.ctx)
userID := authx.UserID(l.ctx)
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
if err != nil {
return nil, err
}
if !hasOp(ref, "import") {
return nil, fmt.Errorf("operation import not allowed")
}
ext := strings.ToLower(filepath.Ext(filename))
raw, err := io.ReadAll(r)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, fmt.Errorf("empty file")
}
var rows [][]string
switch {
case ext == ".xlsx" || ext == ".xlsm" || isZipOOXML(raw):
rows, err = readExcelRows(raw)
case ext == ".csv" || ext == ".txt" || looksLikeCSV(raw):
rows, err = readCSVRows(raw)
default:
// try excel then csv
rows, err = readExcelRows(raw)
if err != nil {
rows, err = readCSVRows(raw)
}
}
if err != nil {
return nil, fmt.Errorf("parse file: %w", err)
}
if len(rows) < 1 {
return nil, fmt.Errorf("no header row")
}
headers := make([]string, len(rows[0]))
for i, h := range rows[0] {
headers[i] = strings.TrimSpace(h)
}
resp := &types.ImportResp{Errors: []string{}}
for rowNum := 1; rowNum < len(rows); rowNum++ {
rec := rows[rowNum]
if rowEmpty(rec) {
continue
}
body := map[string]any{}
for i, h := range headers {
if i >= len(rec) || h == "" {
continue
}
fname := mapHeaderToField(ref, h)
if fname == "" || fname == ref.Entity.PrimaryKey {
continue
}
body[fname] = coerceValue(ref, fname, strings.TrimSpace(rec[i]))
}
if len(body) == 0 {
resp.Skipped++
continue
}
if _, err := l.svcCtx.CRUD.Create(l.ctx, ref, tenantID, userID, body); err != nil {
resp.Skipped++
if len(resp.Errors) < 100 {
resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %v", rowNum+1, err))
}
continue
}
resp.Inserted++
}
return resp, nil
}
// ImportCSV kept for callers; prefers CSV parsing.
func (l *CrudLogic) ImportCSV(slug, resource string, r io.Reader) (*types.ImportResp, error) {
return l.ImportRows(slug, resource, "import.csv", r)
}
// ExportExcel writes .xlsx workbook.
func (l *CrudLogic) ExportExcel(slug, resource string) ([]byte, string, error) {
tenantID := authx.TenantID(l.ctx)
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
if err != nil {
return nil, "", err
}
if !hasOp(ref, "export") {
return nil, "", fmt.Errorf("operation export not allowed")
}
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
if err != nil {
return nil, "", err
}
headers := make([]string, 0, len(ref.Entity.Fields))
for _, f := range ref.Entity.Fields {
headers = append(headers, f.Name)
}
f := excelize.NewFile()
sheet := f.GetSheetName(0)
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
_ = f.SetCellValue(sheet, cell, h)
}
for ri, item := range items {
for ci, h := range headers {
cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2)
if v, ok := item[h]; ok && v != nil {
_ = f.SetCellValue(sheet, cell, v)
}
}
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, "", err
}
return buf.Bytes(), resource + ".xlsx", nil
}
// ExportCSV kept for format=csv.
func (l *CrudLogic) ExportCSV(slug, resource string) ([]byte, error) {
tenantID := authx.TenantID(l.ctx)
ref, err := l.svcCtx.Meta.ResolveResource(l.ctx, tenantID, slug, resource)
if err != nil {
return nil, err
}
if !hasOp(ref, "export") {
return nil, fmt.Errorf("operation export not allowed")
}
items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "")
if err != nil {
return nil, err
}
headers := make([]string, 0, len(ref.Entity.Fields))
for _, f := range ref.Entity.Fields {
headers = append(headers, f.Name)
}
buf := &bytes.Buffer{}
w := csv.NewWriter(buf)
_ = w.Write(headers)
for _, item := range items {
row := make([]string, len(headers))
for i, h := range headers {
if v, ok := item[h]; ok && v != nil {
row[i] = fmt.Sprint(v)
}
}
_ = w.Write(row)
}
w.Flush()
return buf.Bytes(), w.Error()
}
func readExcelRows(raw []byte) ([][]string, error) {
f, err := excelize.OpenReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("excel has no sheets")
}
// 优先业务数据页,跳过说明/超限汇总
prefer := ""
for _, name := range sheets {
n := strings.ToLower(name)
if strings.Contains(n, "settlement") || (strings.Contains(name, "测点") && !strings.Contains(name, "超限") && !strings.Contains(name, "说明")) {
prefer = name
break
}
}
if prefer == "" {
for _, name := range sheets {
if !strings.Contains(name, "说明") && !strings.Contains(name, "超限") {
prefer = name
break
}
}
}
if prefer == "" {
prefer = sheets[0]
}
return f.GetRows(prefer)
}
func readCSVRows(raw []byte) ([][]string, error) {
cr := csv.NewReader(bytes.NewReader(raw))
cr.FieldsPerRecord = -1
return cr.ReadAll()
}
func isZipOOXML(raw []byte) bool {
// xlsx is a zip archive
return len(raw) >= 4 && raw[0] == 'P' && raw[1] == 'K' && raw[2] == 3 && raw[3] == 4
}
func looksLikeCSV(raw []byte) bool {
sample := raw
if len(sample) > 512 {
sample = sample[:512]
}
s := string(sample)
return strings.Contains(s, ",") || strings.Contains(s, "\t") || strings.Contains(s, ";")
}
func rowEmpty(rec []string) bool {
for _, c := range rec {
if strings.TrimSpace(c) != "" {
return false
}
}
return true
}
func hasOp(ref *meta.ResourceRef, op string) bool {
for _, o := range ref.Resource.Operations {
if o == op {
return true
}
}
return false
}
func mapHeaderToField(ref *meta.ResourceRef, header string) string {
h := strings.TrimSpace(header)
if h == "" {
return ""
}
norm := normalizeHeader(h)
aliases := map[string]string{
"编号dk": "dkilo", "测点dk": "dkilo", "dkilo": "dkilo",
"里程": "chainage", "chainage": "chainage",
"测点编号": "point_code", "测点": "point_code", "point_code": "point_code",
"断面类型": "section_type", "section_type": "section_type",
"工点": "worksite", "worksite": "worksite",
"cjl数值": "cjl_value", "观测值": "cjl_value", "观测值mm": "cjl_value", "cjl_value": "cjl_value",
"cjl颜色": "cjl_color", "观测色": "cjl_color", "cjl_color": "cjl_color",
"设计沉降": "design_settlement_mm", "设计总沉降量mm": "design_settlement_mm", "design_settlement_mm": "design_settlement_mm",
"累积沉降": "cum_settlement_mm", "累积沉降量mm": "cum_settlement_mm", "cum_settlement_mm": "cum_settlement_mm",
"预测沉降": "pred_settlement_mm", "预测沉降mm": "pred_settlement_mm", "pred_settlement_mm": "pred_settlement_mm",
"超限量": "exceed_mm", "超限量mm": "exceed_mm", "exceed_mm": "exceed_mm",
"超限累计天数": "exceed_days", "累计天数天": "exceed_days", "exceed_days": "exceed_days",
"监督天": "supervise_days", "监督周期天": "supervise_days", "supervise_days": "supervise_days",
"超期天数": "overdue_days", "overdue_days": "overdue_days",
"前一日占比": "before_day", "before_day": "before_day", "beforeday": "before_day",
"后一日占比": "next_day", "next_day": "next_day", "nextday": "next_day",
"频率": "frequency", "frequency": "frequency",
"cljd标识": "workinfo_kilo", "workinfo_kilo": "workinfo_kilo",
"状态": "status", "status": "status",
"里程米": "mileage_m", "mileage_m": "mileage_m",
}
if a, ok := aliases[norm]; ok {
h = a
norm = a
}
for _, f := range ref.Entity.Fields {
if f.Name == h || f.Label == header || normalizeHeader(f.Label) == norm || f.Name == norm {
return f.Name
}
}
return ""
}
func normalizeHeader(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
repl := strings.NewReplacer(" ", "", "_", "", "-", "", "(", "", ")", "", "", "", "", "", ".", "")
return repl.Replace(s)
}
func coerceValue(ref *meta.ResourceRef, fieldName, raw string) any {
if raw == "" {
return nil
}
for _, f := range ref.Entity.Fields {
if f.Name != fieldName {
continue
}
switch f.Type {
case "int", "bigint":
n, err := strconv.ParseInt(raw, 10, 64)
if err == nil {
return n
}
case "decimal":
n, err := strconv.ParseFloat(raw, 64)
if err == nil {
return n
}
case "boolean":
return raw == "1" || strings.EqualFold(raw, "true") || raw == "是"
}
return raw
}
return raw
}