修复 docs/claude代码分析.md 中等优先级的 8 项确认问题: - tenant/project.go:ProjectUpdate/Archive/Unarchive 的 UPDATE 补上 tenant_id 过滤,恢复写操作租户隔离约定 - taskchannel(ali/sora/suno/volcengine/midjourney):裸 http.Client 改为 common.NewPooledClient(走连接池 + 尊重渠道 UseProxy),不再丢弃 BuildRequestHeader 的错误 - handler/public/payment.go:支付回调失败补日志,伪造/篡改尝试可追溯 - billing/wallet.go + payment/fulfill.go:新增导出 InvalidateWallet 同时清进程内 walletCache 与 Redis,消除充值后读到旧余额 - logic/task/task.go:ExecuteTask 领取任务改为原子 CAS,多副本不再重复执行 - middleware/rbac.go:matchPermission 后缀匹配改为按路径分段边界, 修复 /test 规则误命中 .../latest - admin/channel.go + channel_oauth.go:渠道→密钥跨表写入包进事务, 杜绝孤儿渠道 - admin/promo_code.go:CreatePromoCode 增加字段白名单,对齐 UpdatePromoCode 另有 6 项经评估暂不修改(配额非原子/补扣负数/无 jti token/setup 竞态/ crypto AAD/turnstile 校验),原因见分析报告说明。
224 lines
6.1 KiB
Go
224 lines
6.1 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
"github.com/gogf/gf/v2/util/gconv"
|
|
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
|
|
v1 "github.com/qianfree/team-api/api/admin/v1"
|
|
"github.com/qianfree/team-api/internal/utility/export"
|
|
)
|
|
|
|
// ListPromoCodes 获取优惠码列表
|
|
func (s *sAdmin) ListPromoCodes(ctx context.Context, req *v1.PromoCodeListReq) (*v1.PromoCodeListRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
var total int
|
|
items := make([]*v1.PromoCodeItem, 0)
|
|
err := dao.OrdPromoCodes.Ctx(ctx).
|
|
OrderDesc("created_at").
|
|
Page(page, pageSize).
|
|
ScanAndCount(&items, &total, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.PromoCodeListRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// CreatePromoCode 创建优惠码
|
|
func (s *sAdmin) CreatePromoCode(ctx context.Context, req *v1.PromoCodeCreateReq) (*v1.PromoCodeCreateRes, error) {
|
|
// 字段白名单:req.Data 是原始 map,直接 Insert 会把 used_count / id / created_at 等
|
|
// 非预期字段一并写入。此处仅放行显式列出的列(与 UpdatePromoCode 的白名单口径一致),
|
|
// 杜绝请求体越权注入内部字段。
|
|
data := do.OrdPromoCodes{}
|
|
for k, v := range req.Data {
|
|
switch k {
|
|
case "code":
|
|
data.Code = v
|
|
case "name":
|
|
data.Name = v
|
|
case "type":
|
|
data.Type = v
|
|
case "discount_value":
|
|
data.DiscountValue = v
|
|
case "min_amount":
|
|
data.MinAmount = v
|
|
case "max_discount":
|
|
data.MaxDiscount = v
|
|
case "total_count":
|
|
data.TotalCount = v
|
|
case "per_user_limit":
|
|
data.PerUserLimit = v
|
|
case "status":
|
|
data.Status = v
|
|
case "valid_from":
|
|
data.ValidFrom = gconv.GTime(v)
|
|
case "valid_to":
|
|
data.ValidTo = gconv.GTime(v)
|
|
case "plan_ids":
|
|
data.PlanIds = gconv.Int64s(v)
|
|
}
|
|
}
|
|
if data.Code == nil || gconv.String(data.Code) == "" {
|
|
return nil, common.NewBadRequestError("优惠码 code 不能为空")
|
|
}
|
|
|
|
result, err := dao.OrdPromoCodes.Ctx(ctx).Insert(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &v1.PromoCodeCreateRes{ID: id}, nil
|
|
}
|
|
|
|
// UpdatePromoCode 更新优惠码
|
|
func (s *sAdmin) UpdatePromoCode(ctx context.Context, req *v1.PromoCodeUpdateReq) (*v1.PromoCodeUpdateRes, error) {
|
|
count, err := dao.OrdPromoCodes.Ctx(ctx).Where("id", req.Id).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
return nil, common.NewNotFoundError("优惠码")
|
|
}
|
|
|
|
data := do.OrdPromoCodes{}
|
|
allowedFields := map[string]bool{
|
|
"name": true, "type": true, "discount_value": true,
|
|
"min_amount": true, "total_count": true,
|
|
"max_discount": true, "status": true,
|
|
"starts_at": true, "expires_at": true,
|
|
}
|
|
for k, v := range req.Update {
|
|
if !allowedFields[k] {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "name":
|
|
data.Name = v.(string)
|
|
case "type":
|
|
data.Type = v.(string)
|
|
case "discount_value":
|
|
data.DiscountValue = v
|
|
case "min_amount":
|
|
data.MinAmount = v
|
|
case "total_count":
|
|
data.TotalCount = v
|
|
case "max_discount":
|
|
data.MaxDiscount = v
|
|
case "status":
|
|
data.Status = v.(string)
|
|
}
|
|
}
|
|
|
|
_, err = dao.OrdPromoCodes.Ctx(ctx).
|
|
Where("id", req.Id).
|
|
Data(data).
|
|
Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &v1.PromoCodeUpdateRes{}, nil
|
|
}
|
|
|
|
// GetPromoCodeUsages 获取优惠码使用记录
|
|
func (s *sAdmin) GetPromoCodeUsages(ctx context.Context, req *v1.PromoCodeUsagesReq) (*v1.PromoCodeUsagesRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
var total int
|
|
items := make([]*v1.PromoCodeUsageItem, 0)
|
|
err := dao.OrdPromoCodeUsages.Ctx(ctx).
|
|
Where("promo_code_id", req.Id).
|
|
OrderDesc("created_at").
|
|
Page(page, pageSize).
|
|
ScanAndCount(&items, &total, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.PromoCodeUsagesRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// ExportPromoCodes exports promo code list to CSV or Excel.
|
|
func (s *sAdmin) ExportPromoCodes(ctx context.Context, req *v1.PromoCodeExportReq) (*v1.PromoCodeExportRes, error) {
|
|
columns := []export.Column{
|
|
{Field: "id", Header: "ID"},
|
|
{Field: "code", Header: "优惠码"},
|
|
{Field: "name", Header: "名称"},
|
|
{Field: "type", Header: "类型"},
|
|
{Field: "discount_value", Header: "折扣值"},
|
|
{Field: "min_amount", Header: "最低金额"},
|
|
{Field: "total_count", Header: "总数"},
|
|
{Field: "used_count", Header: "已用数"},
|
|
{Field: "status", Header: "状态"},
|
|
{Field: "created_at", Header: "创建时间"},
|
|
}
|
|
|
|
config := export.Config{
|
|
Format: req.Format,
|
|
Filename: "优惠码_" + gtime.Now().Format("Ymd_His"),
|
|
Columns: columns,
|
|
}
|
|
|
|
promoFields := "id, code, name, type, discount_value, min_amount, total_count, used_count, status, created_at"
|
|
|
|
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
|
|
offset := 0
|
|
for {
|
|
var batch []struct {
|
|
Id int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
DiscountValue float64 `json:"discount_value"`
|
|
MinAmount float64 `json:"min_amount"`
|
|
TotalCount int `json:"total_count"`
|
|
UsedCount int `json:"used_count"`
|
|
Status string `json:"status"`
|
|
CreatedAt *gtime.Time `json:"created_at"`
|
|
}
|
|
if err := dao.OrdPromoCodes.Ctx(ctx).Fields(promoFields).OrderDesc("created_at").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
|
return
|
|
}
|
|
for _, item := range batch {
|
|
if !yield(map[string]any{
|
|
"id": item.Id,
|
|
"code": item.Code,
|
|
"name": item.Name,
|
|
"type": item.Type,
|
|
"discount_value": item.DiscountValue,
|
|
"min_amount": item.MinAmount,
|
|
"total_count": item.TotalCount,
|
|
"used_count": item.UsedCount,
|
|
"status": item.Status,
|
|
"created_at": item.CreatedAt.String(),
|
|
}) {
|
|
return
|
|
}
|
|
}
|
|
if len(batch) < 1000 {
|
|
break
|
|
}
|
|
offset += 1000
|
|
}
|
|
})
|
|
}
|