package srv_contentexcel import ( "context" "fmt" "io" "math/rand" "strconv" "strings" "time" "videoweb/pub/common/srvdef" "videoweb/pub/models/videoinformationmod" "videoweb/pub/pubdef" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const queryBatchSize = 500 var importAddedTimeStart = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local) func randomImportAddedTime(r *rand.Rand) time.Time { now := time.Now() if !now.After(importAddedTimeStart) { return now } if r == nil { r = rand.New(rand.NewSource(time.Now().UnixNano())) } delta := now.UnixNano() - importAddedTimeStart.UnixNano() if delta <= 0 { return now } return importAddedTimeStart.Add(time.Duration(r.Int63n(delta + 1))) } // Export 按条件导出视频 SEO 字段为 Excel。 func Export(ctx context.Context, req *ExportReq) ([]byte, string, *srvdef.SrvErr) { contentType, serr := normalizeContentType(req.Type) if serr != nil { return nil, "", serr } dateFilter, serr := buildTimeRangeFilter(req.StartTime, req.EndTime) if serr != nil { return nil, "", serr } ids, serr := normalizeExportIDs(req.Ids) if serr != nil { return nil, "", serr } var rows []ContentRow var err error switch contentType { case ContentTypeVideo: rows, err = queryVideoRows(ctx, dateFilter, req.Status, ids) } if err != nil { return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error()) } data, err := buildWorkbook(rows) if err != nil { return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error()) } filename := fmt.Sprintf("%s_content_%s.xlsx", contentType, time.Now().Format("20060102150405")) return data, filename, nil } // Import 读取 Excel 并按 ID 批量更新标题、描述、关键词。 func Import(ctx context.Context, contentType string, reader io.Reader) (*ImportResult, *srvdef.SrvErr) { normalizedType, serr := normalizeContentType(contentType) if serr != nil { return nil, serr } data, err := io.ReadAll(reader) if err != nil { return nil, srvdef.New(srvdef.ErrParamError, "读取上传文件失败") } rows, err := parseWorkbook(data) if err != nil { return nil, srvdef.New(srvdef.ErrParamError, err.Error()) } result := &ImportResult{Total: len(rows)} r := rand.New(rand.NewSource(time.Now().UnixNano())) for _, row := range rows { var updateErr *srvdef.SrvErr switch normalizedType { case ContentTypeVideo: updateErr = updateVideoRow(ctx, row, r) } if updateErr != nil { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("ID=%d: %s", row.ID, updateErr.Tips)) continue } result.Success++ } return result, nil } func normalizeContentType(raw string) (string, *srvdef.SrvErr) { switch stringsTrimLower(raw) { case ContentTypeVideo: return stringsTrimLower(raw), nil default: return "", srvdef.New(srvdef.ErrParamError, "type 仅支持 video") } } func stringsTrimLower(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func buildTimeRangeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) { filter := bson.M{} start, err := parseOptionalTime(startTime, false) if err != nil { return nil, srvdef.New(srvdef.ErrParamError, "startTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05") } end, err := parseOptionalTime(endTime, true) if err != nil { return nil, srvdef.New(srvdef.ErrParamError, "endTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05") } if start != nil && end != nil && start.After(*end) { return nil, srvdef.New(srvdef.ErrParamError, "startTime 不能晚于 endTime") } if start != nil { filter["$gte"] = *start } if end != nil { filter["$lte"] = *end } if len(filter) == 0 { return nil, nil } return filter, nil } func parseOptionalTime(raw string, endOfDay bool) (*time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } layouts := []string{ "2006-01-02 15:04:05", "2006-01-02T15:04:05", time.RFC3339, } for _, layout := range layouts { if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { return &t, nil } } if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil { if endOfDay { end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) return &end, nil } return &t, nil } return nil, fmt.Errorf("invalid time: %s", raw) } func applyTimeFilter(base bson.M, field string, rangeFilter bson.M) bson.M { if len(rangeFilter) == 0 { return base } base[field] = rangeFilter return base } func applyStatusFilter(base bson.M, field string, status *pubdef.InformationStatus) bson.M { if status == nil { return base } base[field] = *status return base } func applyIDsFilter(base bson.M, field string, ids []int64) bson.M { if len(ids) == 0 { return base } base[field] = bson.M{"$in": ids} return base } // ParseExportIDs 解析导出接口的 ids 查询参数,支持 ids=1&ids=2 或 ids=1,2,3。 func ParseExportIDs(raw []string) ([]int64, *srvdef.SrvErr) { if len(raw) == 0 { return nil, nil } ids := make([]int64, 0, len(raw)) seen := make(map[int64]struct{}, len(raw)) for _, item := range raw { item = strings.TrimSpace(item) if item == "" { continue } for _, part := range strings.Split(item, ",") { part = strings.TrimSpace(part) if part == "" { continue } id, err := strconv.ParseInt(part, 10, 64) if err != nil || id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数") } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } } return ids, nil } func normalizeExportIDs(raw []int64) ([]int64, *srvdef.SrvErr) { if len(raw) == 0 { return nil, nil } ids := make([]int64, 0, len(raw)) seen := make(map[int64]struct{}, len(raw)) for _, id := range raw { if id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数") } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } return ids, nil } func queryVideoRows(ctx context.Context, dateFilter bson.M, status *pubdef.InformationStatus, ids []int64) ([]ContentRow, error) { filter := applyIDsFilter(applyStatusFilter(applyTimeFilter(bson.M{ videoinformationmod.FN.VideoInformation.MediaType: bson.M{ "$in": []videoinformationmod.MediaType{ videoinformationmod.MediaType_Video, videoinformationmod.MediaType_AV, }, }, }, videoinformationmod.FN.VideoInformation.CreatedAt, dateFilter), videoinformationmod.FN.VideoInformation.Status, status), videoinformationmod.FN.VideoInformation.Id, ids) return queryRowsInBatches(ctx, func(skip int64) ([]ContentRow, bool, error) { opt := options.Find(). SetSkip(skip). SetLimit(queryBatchSize). SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}). SetProjection(bson.M{ videoinformationmod.FN.VideoInformation.Id: 1, videoinformationmod.FN.VideoInformation.Title: 1, videoinformationmod.FN.VideoInformation.Desc: 1, videoinformationmod.FN.VideoInformation.Keyworld: 1, }) list, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt) if serr != nil { return nil, false, fmt.Errorf("%s", serr.Tips) } rows := make([]ContentRow, 0, len(list)) for _, item := range list { if item == nil { continue } rows = append(rows, ContentRow{ ID: item.Id, Title: item.Title, Desc: item.Desc, Keyworld: item.Keyworld, }) } return rows, int64(len(list)) == queryBatchSize, nil }) } func queryRowsInBatches(ctx context.Context, fetch func(skip int64) ([]ContentRow, bool, error)) ([]ContentRow, error) { all := make([]ContentRow, 0) var skip int64 for { rows, hasMore, err := fetch(skip) if err != nil { return nil, err } all = append(all, rows...) if !hasMore { break } skip += queryBatchSize } return all, nil } func updateVideoRow(ctx context.Context, row ContentRow, r *rand.Rand) *srvdef.SrvErr { now := time.Now() addedTime := randomImportAddedTime(r) update := bson.M{ "$set": bson.M{ videoinformationmod.FN.VideoInformation.Title: row.Title, videoinformationmod.FN.VideoInformation.Desc: row.Desc, videoinformationmod.FN.VideoInformation.Keyworld: row.Keyworld, videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, videoinformationmod.FN.VideoInformation.AddedTime: addedTime, videoinformationmod.FN.VideoInformation.UpdatedAt: now, }, } return videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ videoinformationmod.FN.VideoInformation.Id: row.ID, }, update) }