tenant: admin order export csv

This commit is contained in:
2025-12-19 09:11:28 +08:00
parent 549339be74
commit 86a1a0a2cc
9 changed files with 718 additions and 0 deletions

View File

@@ -1,7 +1,9 @@
package services
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
@@ -26,6 +28,158 @@ import (
"go.ipao.vip/gen/types"
)
// AdminOrderExportCSV 租户管理员导出订单列表CSV 文本)。
func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*dto.AdminOrderExportResponse, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminOrderListFilter{}
}
// 导出属于高消耗操作:限制最大行数,避免拖垮数据库。
const maxRows = 5000
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"max_rows": maxRows,
"user_id": lo.FromPtr(filter.UserID),
"username": filter.UsernameTrimmed(),
"content_id": lo.FromPtr(filter.ContentID),
"content_title": filter.ContentTitleTrimmed(),
"type": lo.FromPtr(filter.Type),
"status": lo.FromPtr(filter.Status),
}).Info("services.order.admin.export_csv")
tbl, query := models.OrderQuery.QueryContext(ctx)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.UserID != nil {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil {
conds = append(conds, tbl.Type.Eq(*filter.Type))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.AmountPaidMin != nil {
conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
if username := filter.UsernameTrimmed(); username != "" {
uTbl, _ := models.UserQuery.QueryContext(ctx)
query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID))
conds = append(conds, uTbl.Username.Like(database.WrapLike(username)))
}
needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != ""
if needItemJoin {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
if filter.ContentID != nil && *filter.ContentID > 0 {
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
}
if title := filter.ContentTitleTrimmed(); title != "" {
cTbl, _ := models.ContentQuery.QueryContext(ctx)
query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID))
conds = append(conds, cTbl.Title.Like(database.WrapLike(title)))
}
query = query.Group(tbl.ID)
}
// 排序:复用 AdminOrderPage 的白名单,避免任意字段导致注入/慢查询。
orderBys := make([]field.Expr, 0, 4)
allowedAsc := map[string]field.Expr{
"id": tbl.ID.Asc(),
"created_at": tbl.CreatedAt.Asc(),
"paid_at": tbl.PaidAt.Asc(),
"amount_paid": tbl.AmountPaid.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": tbl.ID.Desc(),
"created_at": tbl.CreatedAt.Desc(),
"paid_at": tbl.PaidAt.Desc(),
"amount_paid": tbl.AmountPaid.Desc(),
}
for _, f := range filter.AscFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedAsc[f]; ok {
orderBys = append(orderBys, ob)
}
}
for _, f := range filter.DescFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedDesc[f]; ok {
orderBys = append(orderBys, ob)
}
}
if len(orderBys) == 0 {
orderBys = append(orderBys, tbl.ID.Desc())
} else {
orderBys = append(orderBys, tbl.ID.Desc())
}
items, err := query.Where(conds...).Order(orderBys...).Limit(maxRows).Find()
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
w := csv.NewWriter(buf)
_ = w.Write([]string{"id", "tenant_id", "user_id", "type", "status", "amount_paid", "paid_at", "created_at"})
for _, it := range items {
if it == nil {
continue
}
paidAt := ""
if !it.PaidAt.IsZero() {
paidAt = it.PaidAt.UTC().Format(time.RFC3339)
}
_ = w.Write([]string{
fmt.Sprintf("%d", it.ID),
fmt.Sprintf("%d", it.TenantID),
fmt.Sprintf("%d", it.UserID),
string(it.Type),
string(it.Status),
fmt.Sprintf("%d", it.AmountPaid),
paidAt,
it.CreatedAt.UTC().Format(time.RFC3339),
})
}
w.Flush()
if err := w.Error(); err != nil {
return nil, err
}
filename := fmt.Sprintf("tenant_%d_orders_%s.csv", tenantID, time.Now().UTC().Format("20060102_150405"))
return &dto.AdminOrderExportResponse{
Filename: filename,
ContentType: "text/csv",
CSV: buf.String(),
}, nil
}
// PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。
type PurchaseOrderSnapshot struct {
// ContentID 内容ID。

View File

@@ -827,6 +827,56 @@ func (s *OrderTestSuite) Test_AdminOrderDetail() {
})
}
func (s *OrderTestSuite) Test_AdminOrderExportCSV() {
Convey("Order.AdminOrderExportCSV", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameUser, models.TableNameContent)
Convey("参数非法应返回错误", func() {
_, err := Order.AdminOrderExportCSV(ctx, 0, &dto.AdminOrderListFilter{})
So(err, ShouldNotBeNil)
})
Convey("导出应返回 CSV 且包含表头", func() {
u := &models.User{
Username: "alice",
Password: "x",
Roles: types.NewArray([]consts.Role{consts.RoleUser}),
Status: consts.UserStatusVerified,
Metas: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}
So(u.Create(ctx), ShouldBeNil)
o := &models.Order{
TenantID: tenantID,
UserID: u.ID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 123,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(o.Create(ctx), ShouldBeNil)
resp, err := Order.AdminOrderExportCSV(ctx, tenantID, &dto.AdminOrderListFilter{})
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.ContentType, ShouldEqual, "text/csv")
So(resp.Filename, ShouldContainSubstring, "tenant_1_orders_")
So(resp.CSV, ShouldContainSubstring, "id,tenant_id,user_id,type,status,amount_paid,paid_at,created_at")
So(resp.CSV, ShouldContainSubstring, "content_purchase")
})
})
}
func (s *OrderTestSuite) Test_AdminRefundOrder() {
Convey("Order.AdminRefundOrder", s.T(), func() {
ctx := s.T().Context()