Merge branch 'main' into feature-userinfo-daiyb

This commit is contained in:
戴育兵 2025-12-17 17:00:18 +08:00
commit a7ac8beb3a
16 changed files with 3258 additions and 2165 deletions

File diff suppressed because it is too large Load Diff

View File

@ -483,6 +483,22 @@ func (this *ConfirmWorkReq) Validate() error {
func (this *ConfirmWorkResp) Validate() error {
return nil
}
func (this *ConfirmWorkItem) Validate() error {
return nil
}
func (this *GetWaitConfirmWorkListReq) Validate() error {
return nil
}
func (this *GetWaitConfirmWorkListResp) Validate() error {
for _, item := range this.Data {
if item != nil {
if err := github_com_mwitkow_go_proto_validators.CallValidatorIfExists(item); err != nil {
return github_com_mwitkow_go_proto_validators.FieldError("Data", err)
}
}
}
return nil
}
func (this *AutoCreateUserAndOrderRequest) Validate() error {
return nil
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-triple. DO NOT EDIT.
// versions:
// - protoc-gen-go-triple v1.0.8
// - protoc v3.21.1
// - protoc-gen-go-triple v1.0.5
// - protoc v5.26.0
// source: pb/bundle.proto
package bundle
@ -79,6 +79,7 @@ type BundleClient interface {
GetVedioWorkDetail(ctx context.Context, in *GetVedioWorkDetailReq, opts ...grpc_go.CallOption) (*GetVedioeWorkDetailResp, common.ErrorWithAttachment)
ToBeComfirmedWorks(ctx context.Context, in *ToBeComfirmedWorksReq, opts ...grpc_go.CallOption) (*ToBeComfirmedWorksResp, common.ErrorWithAttachment)
ConfirmWork(ctx context.Context, in *ConfirmWorkReq, opts ...grpc_go.CallOption) (*ConfirmWorkResp, common.ErrorWithAttachment)
GetWaitConfirmWorkList(ctx context.Context, in *GetWaitConfirmWorkListReq, opts ...grpc_go.CallOption) (*GetWaitConfirmWorkListResp, common.ErrorWithAttachment)
// 对账单
GetReconciliationList(ctx context.Context, in *GetReconciliationListReq, opts ...grpc_go.CallOption) (*GetReconciliationListResp, common.ErrorWithAttachment)
CreateReconciliation(ctx context.Context, in *ReconciliationInfo, opts ...grpc_go.CallOption) (*CommonResponse, common.ErrorWithAttachment)
@ -169,6 +170,7 @@ type BundleClientImpl struct {
GetVedioWorkDetail func(ctx context.Context, in *GetVedioWorkDetailReq) (*GetVedioeWorkDetailResp, error)
ToBeComfirmedWorks func(ctx context.Context, in *ToBeComfirmedWorksReq) (*ToBeComfirmedWorksResp, error)
ConfirmWork func(ctx context.Context, in *ConfirmWorkReq) (*ConfirmWorkResp, error)
GetWaitConfirmWorkList func(ctx context.Context, in *GetWaitConfirmWorkListReq) (*GetWaitConfirmWorkListResp, error)
GetReconciliationList func(ctx context.Context, in *GetReconciliationListReq) (*GetReconciliationListResp, error)
CreateReconciliation func(ctx context.Context, in *ReconciliationInfo) (*CommonResponse, error)
UpdateReconciliation func(ctx context.Context, in *ReconciliationInfo) (*CommonResponse, error)
@ -497,6 +499,12 @@ func (c *bundleClient) ConfirmWork(ctx context.Context, in *ConfirmWorkReq, opts
return out, c.cc.Invoke(ctx, "/"+interfaceKey+"/ConfirmWork", in, out)
}
func (c *bundleClient) GetWaitConfirmWorkList(ctx context.Context, in *GetWaitConfirmWorkListReq, opts ...grpc_go.CallOption) (*GetWaitConfirmWorkListResp, common.ErrorWithAttachment) {
out := new(GetWaitConfirmWorkListResp)
interfaceKey := ctx.Value(constant.InterfaceKey).(string)
return out, c.cc.Invoke(ctx, "/"+interfaceKey+"/GetWaitConfirmWorkList", in, out)
}
func (c *bundleClient) GetReconciliationList(ctx context.Context, in *GetReconciliationListReq, opts ...grpc_go.CallOption) (*GetReconciliationListResp, common.ErrorWithAttachment) {
out := new(GetReconciliationListResp)
interfaceKey := ctx.Value(constant.InterfaceKey).(string)
@ -744,6 +752,7 @@ type BundleServer interface {
GetVedioWorkDetail(context.Context, *GetVedioWorkDetailReq) (*GetVedioeWorkDetailResp, error)
ToBeComfirmedWorks(context.Context, *ToBeComfirmedWorksReq) (*ToBeComfirmedWorksResp, error)
ConfirmWork(context.Context, *ConfirmWorkReq) (*ConfirmWorkResp, error)
GetWaitConfirmWorkList(context.Context, *GetWaitConfirmWorkListReq) (*GetWaitConfirmWorkListResp, error)
// 对账单
GetReconciliationList(context.Context, *GetReconciliationListReq) (*GetReconciliationListResp, error)
CreateReconciliation(context.Context, *ReconciliationInfo) (*CommonResponse, error)
@ -929,6 +938,9 @@ func (UnimplementedBundleServer) ToBeComfirmedWorks(context.Context, *ToBeComfir
func (UnimplementedBundleServer) ConfirmWork(context.Context, *ConfirmWorkReq) (*ConfirmWorkResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConfirmWork not implemented")
}
func (UnimplementedBundleServer) GetWaitConfirmWorkList(context.Context, *GetWaitConfirmWorkListReq) (*GetWaitConfirmWorkListResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWaitConfirmWorkList not implemented")
}
func (UnimplementedBundleServer) GetReconciliationList(context.Context, *GetReconciliationListReq) (*GetReconciliationListResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetReconciliationList not implemented")
}
@ -2416,6 +2428,35 @@ func _Bundle_ConfirmWork_Handler(srv interface{}, ctx context.Context, dec func(
return interceptor(ctx, in, info, handler)
}
func _Bundle_GetWaitConfirmWorkList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc_go.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWaitConfirmWorkListReq)
if err := dec(in); err != nil {
return nil, err
}
base := srv.(dubbo3.Dubbo3GrpcService)
args := []interface{}{}
args = append(args, in)
md, _ := metadata.FromIncomingContext(ctx)
invAttachment := make(map[string]interface{}, len(md))
for k, v := range md {
invAttachment[k] = v
}
invo := invocation.NewRPCInvocation("GetWaitConfirmWorkList", args, invAttachment)
if interceptor == nil {
result := base.XXX_GetProxyImpl().Invoke(ctx, invo)
return result, result.Error()
}
info := &grpc_go.UnaryServerInfo{
Server: srv,
FullMethod: ctx.Value("XXX_TRIPLE_GO_INTERFACE_NAME").(string),
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
result := base.XXX_GetProxyImpl().Invoke(ctx, invo)
return result, result.Error()
}
return interceptor(ctx, in, info, handler)
}
func _Bundle_GetReconciliationList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc_go.UnaryServerInterceptor) (interface{}, error) {
in := new(GetReconciliationListReq)
if err := dec(in); err != nil {
@ -3539,6 +3580,10 @@ var Bundle_ServiceDesc = grpc_go.ServiceDesc{
MethodName: "ConfirmWork",
Handler: _Bundle_ConfirmWork_Handler,
},
{
MethodName: "GetWaitConfirmWorkList",
Handler: _Bundle_GetWaitConfirmWorkList_Handler,
},
{
MethodName: "GetReconciliationList",
Handler: _Bundle_GetReconciliationList_Handler,

View File

@ -1,10 +1,15 @@
[system]
Domain = "fiee"
AppMode = "debug"
Domain = "app"
AppMode = "dev"
HttpPort = ":8085"
Host = "http://127.0.0.1:8085"
Host = "https://common.szjixun.cn"
RedirectUri = "/api/redirect/url"
ErpHost = "http://erpapi.test.fontree.cn:8081"
FieeHost = "http://erpapi.test.fontree.cn:8081"
AuthRedirectUrl = "http://saas-erp.test.fontree.cn:8081/media_account"
AuthCallback = "https://saas-test.szjixun.cn/api/fiee/media/as-oauth2callback"
CronOpen = true
proxyUrl = "http://47.84.75.255:6785"
[bos]
Ak = "ALTAKxrqOQHnAN525Tb2GX4Bhe"
Sk = "d2ecaa9d75114d3b9f42b99014198306"
@ -23,7 +28,7 @@ CdnHost = "${OSS_CDN}"
[redis]
RedisDB = "2"
RedisAddr = "127.0.0.1:6379"
RedisPW = ""
RedisPW = "7532T6R"
RedisDBNAme = "2"

View File

@ -2,10 +2,27 @@ dubbo:
registries:
demoZK:
protocol: zookeeper
timeout: 5s
timeout: 3s
# address: 121.229.45.214:9004
address: 127.0.0.1:2181
# address: 127.0.0.1:2181
# address: 114.218.158.24:2181
consumer:
filter: tracing
request-timeout: 300s
references:
OrderClientImpl:
protocol: tri
retries: 0
interface: com.fontree.microservices.common.order # must be compatible with grpc or dubbo-java
# filter: cshutdown,sign,fonDomainFilter,fonValidateFilter
params:
.accessKeyId: "SYD8-order-04"
.secretAccessKey: "Al-order-FDF112"
BundleClientImpl:
protocol: tri
retries: 0
interface: com.fontree.microservices.fiee.bundle # must be compatible with grpc or dubbo-java
AccountClientImpl:
protocol: tri
retries: 0
@ -14,19 +31,62 @@ dubbo:
params:
.accessKeyId: "Accountksl"
.secretAccessKey: "BSDY-FDF1-Fontree_account"
AccountFieeClientImpl:
protocol: tri
retries: 0
retries: 3
interface: com.fontree.microservices.common.micro.account.fiee
# filter: cshutdown,sign,fonDomainFilter,fonValidateFilter
# params:
# .accessKeyId: "Accountksl"
# .secretAccessKey: "BSDY-FDF1-Fontree_account"
BundleClientImpl:
# filter: echo,metrics,token,accesslog,sign,tps,generic_service,execute,pshutdown,auth,fonValidateFilter
PaymentCentClientImpl:
protocol: tri
retries: 0
interface: com.fontree.microservices.fiee.bundle # must be compatible with grpc or dubbo-java
interface: com.fontree.microservices.common.payment.cent # must be compatible with grpc or dubbo-java
CastClientImpl:
protocol: tri
interface: com.fontree.microservices.fiee.multicast
interface: com.fontree.microservices.fiee.multicast
SecFilingsClientImpl:
protocol: tri
retries: 0
interface: com.fontree.microservices.fiee.SecFiling
AyrshareClientImpl:
protocol: tri
interface: com.fontree.microservices.fiee.ayrshare
logger:
zap-config:
level: error # 日志级别
development: false
disableCaller: false
disableStacktrace: false
encoding: "json"
# zap encoder 配置
encoderConfig:
messageKey: "message"
levelKey: "level"
timeKey: "time"
nameKey: "logger"
callerKey: "caller"
stacktraceKey: "stacktrace"
lineEnding: ""
levelEncoder: "capitalColor"
timeEncoder: "iso8601"
durationEncoder: "seconds"
callerEncoder: "short"
nameEncoder: ""
EncodeTime: zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"),
EncodeDuration: zapcore.SecondsDurationEncoder,
outputPaths:
- "stderr"
errorOutputPaths:
- "stderr"
lumberjack-config:
# 写日志的文件名称
filename: "runtime/logs/fiee.log"
# 每个日志文件长度的最大大小,单位是 MiB。默认 100MiB
maxSize: 5
# 日志保留的最大天数(只保留最近多少天的日志)
maxAge: 30
# 只保留最近多少个日志文件,用于控制程序总日志的大小
maxBackups: 30
# 是否使用本地时间,默认使用 UTC 时间
localTime: true
# 是否压缩日志文件,压缩方法 gzip
compress: false

View File

@ -5,12 +5,16 @@ import (
"encoding/json"
"errors"
"fmt"
"fonchain-fiee/api/bundle"
"fonchain-fiee/api/cast"
"fonchain-fiee/pkg/cache"
bundleModel "fonchain-fiee/pkg/model/bundle"
modelCast "fonchain-fiee/pkg/model/cast"
"fonchain-fiee/pkg/service"
serverCast "fonchain-fiee/pkg/service/cast"
"log"
"math/rand"
"strconv"
"time"
"github.com/go-redis/redis"
@ -23,6 +27,7 @@ func InitTasks() error {
err := cm.AddTask("refreshWorkApprovalStatus", "0 */1 * * * *", RefreshWorkApprovalStatusTask)
err = cm.AddTask("artistAutoConfirm", "0 */1 * * * *", ArtistAutoConfirmTask)
err = cm.AddTask("refreshPublishStatus", "0 */1 * * * *", RefreshPublishStatusTask)
err = cm.AddTask("scheduledPublish", "0 */1 * * * *", ScheduledPublishTask)
if err != nil {
log.Printf("添加定时任务失败: %v", err)
}
@ -31,6 +36,9 @@ func InitTasks() error {
// 启动队列消费者
go WorkPublishQueueConsumer()
// 启动随机间隔的自动确认任务
go AutoManuallyConfirmWorkTaskWithRandomInterval()
return nil
}
@ -50,6 +58,127 @@ func RefreshWorkApprovalStatusTask() {
serverCast.RefreshWorkApproval(nil, resp.Data)
}
// AutoManuallyConfirmWorkTaskWithRandomInterval 以随机间隔3-7分钟执行自动确认任务
func AutoManuallyConfirmWorkTaskWithRandomInterval() {
for {
// 执行任务
AutoManuallyConfirmWorkTask()
// 生成3-7分钟之间的随机间隔单位分钟
randomMinutes := rand.Intn(5) + 3 // 3-7分钟
randomDuration := time.Duration(randomMinutes) * time.Minute
// 等待随机时间
time.Sleep(randomDuration)
}
}
func AutoManuallyConfirmWorkTask() {
var req bundle.GetWaitConfirmWorkListReq
res, err := service.BundleProvider.GetWaitConfirmWorkList(context.Background(), &req)
if err != nil {
zap.L().Error("获取待确认作品列表失败", zap.Error(err))
return
}
if res.Data == nil || len(res.Data) == 0 {
return
}
for _, work := range res.Data {
var req bundleModel.UserWorkConfirmReq
req.WorkUuid = work.WorkUuid
req.ConfirmStatus = 1
artistId, err := strconv.ParseInt(work.ArtistUuid, 10, 64)
if err != nil {
zap.L().Error("解析艺术家ID失败", zap.Error(err))
return
}
if req.ConfirmStatus == 2 { // 驳回完直接结束
_, err := service.CastProvider.UpdateStatus(context.Background(), &cast.UpdateStatusReq{
WorkAction: cast.WorkActionENUM_CONFIRM,
WorkUuid: req.WorkUuid,
ConfirmRemark: req.ConfirmRemark,
ConfirmStatus: 2,
})
if err != nil {
zap.L().Error("确认作品失败", zap.Error(err))
return
}
continue
}
balanceInfoRes, err := service.BundleProvider.GetBundleBalanceByUserId(context.Background(), &bundle.GetBundleBalanceByUserIdReq{
UserId: int32(artistId),
})
if err != nil {
continue
}
wordInfoRes, err := service.CastProvider.WorkDetail(context.Background(), &cast.WorkDetailReq{
WorkUuid: req.WorkUuid,
})
if err != nil {
continue
}
if wordInfoRes.WorkStatus != 4 {
continue
}
var workCategory = wordInfoRes.WorkCategory
var addBalanceReq bundle.AddBundleBalanceReq
addBalanceReq.UserId = int32(artistId)
log.Printf("开始确认作品uuid:" + req.WorkUuid)
switch workCategory {
case 1:
{
if balanceInfoRes.ImageConsumptionNumber >= balanceInfoRes.ImageExtendNumber { // 图文余量不足
_, err = service.CastProvider.UpdateStatus(context.Background(), &cast.UpdateStatusReq{
WorkAction: cast.WorkActionENUM_CONFIRM,
WorkUuid: req.WorkUuid,
ConfirmRemark: req.ConfirmRemark,
ConfirmStatus: 3,
})
log.Printf("图文余量不足,作品uuid:"+req.WorkUuid, zap.Error(err))
continue
}
addBalanceReq.ImageConsumptionNumber = 1
}
case 2:
{
if balanceInfoRes.VideoConsumptionNumber >= balanceInfoRes.VideoExtendNumber { // 视频余量不足
_, err = service.CastProvider.UpdateStatus(context.Background(), &cast.UpdateStatusReq{
WorkAction: cast.WorkActionENUM_CONFIRM,
WorkUuid: req.WorkUuid,
ConfirmRemark: req.ConfirmRemark,
ConfirmStatus: 3,
})
log.Printf("视频余量不足,作品uuid:"+req.WorkUuid, zap.Error(err))
continue
}
addBalanceReq.VideoConsumptionNumber = 1
}
default:
continue
}
resp, err := service.BundleProvider.AddBundleBalance(context.Background(), &addBalanceReq)
if err != nil {
log.Printf("扣除余额失败,作品uuid:"+req.WorkUuid, zap.Error(err))
continue
}
log.Printf("扣除余额成功,作品uuid:" + req.WorkUuid)
_, err = service.CastProvider.UpdateStatus(context.Background(), &cast.UpdateStatusReq{
WorkAction: cast.WorkActionENUM_CONFIRM,
WorkUuid: req.WorkUuid,
ConfirmRemark: req.ConfirmRemark,
CostType: resp.UsedType,
ConfirmStatus: 1,
})
if err != nil {
log.Printf("更新作品状态失败,作品uuid:"+req.WorkUuid, zap.Error(err))
return
}
}
}
func ArtistAutoConfirmTask() {
now := float64(time.Now().Unix())
opt := redis.ZRangeBy{
@ -90,6 +219,82 @@ func RefreshPublishStatusTask() {
zap.L().Info("刷新发布状态成功")
}
// ScheduledPublishTask 定时发布任务从Redis Sorted Set中获取所有workUuid并根据score判断处理
func ScheduledPublishTask() {
// 加上锁
lockKey := "scheduled_publish:lock"
reply := cache.RedisClient.SetNX(lockKey, time.Now().Format("2006-01-02 15:04:05"), 1*time.Hour)
if !reply.Val() {
zap.L().Warn("定时发布任务正在被其他实例处理")
return
}
defer func() {
cache.RedisClient.Del(lockKey)
}()
// 获取所有数据不限制score范围
opt := redis.ZRangeBy{
Min: "-inf",
Max: "+inf",
}
// 从Redis Sorted Set中获取所有workUuid及其score
workList, err := cache.RedisClient.ZRangeByScoreWithScores(modelCast.ScheduledPublishQueueKey, opt).Result()
if err != nil {
zap.L().Error("获取定时发布任务失败", zap.Error(err))
return
}
if len(workList) == 0 {
zap.L().Debug("没有定时发布任务")
return
}
publishCount := 0
expiredCount := 0
zap.L().Info("发现定时发布任务", zap.Int("total_count", len(workList)))
// 遍历所有数据根据score判断处理
for _, item := range workList {
workUuid := item.Member.(string)
score := item.Score
now := float64(time.Now().Unix())
// 如果score小于当前时间删除但不消费不发布
if score < now {
zap.L().Info("发现过期的定时发布任务,直接删除不发布",
zap.String("work_uuid", workUuid),
zap.Float64("score", score),
zap.Float64("now", now))
removed, delErr := cache.RedisClient.ZRem(modelCast.ScheduledPublishQueueKey, workUuid).Result()
if delErr != nil {
zap.L().Error("删除过期定时发布任务失败", zap.Error(delErr), zap.String("work_uuid", workUuid))
} else if removed > 0 {
expiredCount++
zap.L().Info("已删除过期定时发布任务", zap.String("work_uuid", workUuid))
}
continue
}
// score大于等于当前时间正常发布
zap.L().Info("处理定时发布任务",
zap.String("work_uuid", workUuid),
zap.Float64("score", score))
_ = serverCast.PublishWork(context.Background(), &cast.PublishReq{
WorkUuids: []string{workUuid},
})
removed, delErr := cache.RedisClient.ZRem(modelCast.ScheduledPublishQueueKey, workUuid).Result()
if delErr != nil {
zap.L().Error("删除定时发布任务失败", zap.Error(delErr), zap.String("work_uuid", workUuid))
} else if removed > 0 {
publishCount++
zap.L().Info("已发布并删除定时发布任务", zap.String("work_uuid", workUuid))
}
}
zap.L().Info("定时发布任务处理完成",
zap.Int("published_count", publishCount),
zap.Int("expired_count", expiredCount),
zap.Int("total_count", len(workList)))
}
// WorkPublishQueueConsumer 监听work:publish:queue队列的消费者
func WorkPublishQueueConsumer() {
zap.L().Info("开始监听work:publish:queue队列")

View File

@ -155,6 +155,11 @@ const (
ErrorBalanceInsufficient = "余额不足"
)
const (
BundlePurchaseExport = 1
BundleDetailExport = 2
)
// GetMsg 获取状态码对应信息
func GetMsg(code int) string {
msg, ok := MsgFlags[code]

View File

@ -11,9 +11,10 @@ const (
)
const (
AutoConfirmQueueKey = "auto_confirm:queue"
AutoConfirmLockKey = "auto_confirm:lock:%s"
WorkPublishQueueKey = "work:publish:queue"
AutoConfirmQueueKey = "auto_confirm:queue"
AutoConfirmLockKey = "auto_confirm:lock:%s"
WorkPublishQueueKey = "work:publish:queue"
ScheduledPublishQueueKey = "scheduled:publish:queue" // 定时发布队列
)
var WorkCategoryMM = map[int]string{

View File

@ -208,7 +208,7 @@ func WorkConfirm(c *gin.Context) { // 确认作品并扣除余量
switch workCategory {
case 1:
{
if balanceInfoRes.ImageConsumptionNumber >= balanceInfoRes.ImageNumber { // 图文余量不足
if balanceInfoRes.ImageConsumptionNumber >= balanceInfoRes.ImageExtendNumber { // 图文余量不足
service.Error(c, errors.New("图文余量不足"))
return
}
@ -216,7 +216,7 @@ func WorkConfirm(c *gin.Context) { // 确认作品并扣除余量
}
case 2:
{
if balanceInfoRes.VideoConsumptionNumber >= balanceInfoRes.VideoNumber { // 视频余量不足
if balanceInfoRes.VideoConsumptionNumber >= balanceInfoRes.VideoExtendNumber { // 视频余量不足
service.Error(c, errors.New("视频余量不足"))
return
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"fonchain-fiee/api/bundle"
"fonchain-fiee/api/cast"
"fonchain-fiee/pkg/e"
logicCast "fonchain-fiee/pkg/logic/cast"
"fonchain-fiee/pkg/model/login"
"fonchain-fiee/pkg/service"
@ -97,13 +98,13 @@ func MetricsBundlePurchaseExport(ctx *gin.Context) {
sumFee = sumFee.Add(decimal.NewFromFloat(float64(i.FeeAmount)))
}
f.SetCellValue(sheet, fmt.Sprintf("A%d", endRow), "合计支付金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("B%d", endRow), "合计结算金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("C%d", endRow), "合计手续费金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("A%d", endRow+1), "合计支付金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("B%d", endRow+1), "合计结算金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("C%d", endRow+1), "合计手续费金额(美元)")
f.SetCellValue(sheet, fmt.Sprintf("A%d", endRow+1), "$"+sumPayment.StringFixed(2))
f.SetCellValue(sheet, fmt.Sprintf("B%d", endRow+1), "$"+sumFinal.StringFixed(2))
f.SetCellValue(sheet, fmt.Sprintf("C%d", endRow+1), "$"+sumFee.StringFixed(2))
f.SetCellValue(sheet, fmt.Sprintf("A%d", endRow+2), "$"+sumPayment.StringFixed(2))
f.SetCellValue(sheet, fmt.Sprintf("B%d", endRow+2), "$"+sumFinal.StringFixed(2))
f.SetCellValue(sheet, fmt.Sprintf("C%d", endRow+2), "$"+sumFee.StringFixed(2))
// 创建黑色边框样式
borderStyle, err := f.NewStyle(&excelize.Style{
@ -124,8 +125,8 @@ func MetricsBundlePurchaseExport(ctx *gin.Context) {
}
// 应用样式到合计区域(包括标题行和数值行)
startCell := fmt.Sprintf("A%d", endRow)
endCell := fmt.Sprintf("C%d", endRow+1)
startCell := fmt.Sprintf("A%d", endRow+1)
endCell := fmt.Sprintf("C%d", endRow+2)
if err := f.SetCellStyle(sheet, startCell, endCell, borderStyle); err != nil {
fmt.Println("设置边框样式失败:", err)
}
@ -133,8 +134,8 @@ func MetricsBundlePurchaseExport(ctx *gin.Context) {
}
if err := exportStructToExcel(resp.Data, []string{
"订单编号", "套餐", "用户编号", "客户姓名", "手机号", "支付时间", "套餐视频数", "增值视频数", "套餐金额", "增值金额", "支付金额", "结算金额", "手续费", "汇率(%",
}, filePath, statistic); err != nil {
"订单编号", "套餐", "用户编号", "客户姓名", "手机号", "支付时间", "增值视频数", "套餐金额", "增值金额", "支付金额", "结算金额", "手续费", "汇率(%",
}, filePath, e.BundlePurchaseExport, req.EndTime, statistic); err != nil {
service.Error(ctx, errors.New(common.MetricsBundlePurchaseExportFailed))
return
}
@ -441,7 +442,7 @@ func MetricsBalanceDetailExport(ctx *gin.Context) {
"所属月份", "用户编号", "姓名", "手机号", "购买套餐时间", "套餐金额", "增值金额", "支付金额", "币种", "手续费", "套餐视频总数",
"增值视频总数", "套餐视频单价", "增值视频单价", "当前需要上传套餐视频数", "当前需要上传增值视频数",
"当前已上传套餐视频数", "当前已上传增值视频数", "当前套餐视频已消费总金额", "当前增值视频已消费总金额",
}, filePath, yelloStyle, statistic); err != nil {
}, filePath, e.BundleDetailExport, "", statistic); err != nil {
service.Error(ctx, errors.New(common.MetricsBalanceDetailExportFailed))
return
}
@ -518,11 +519,28 @@ func BalanceMetricsExport(ctx *gin.Context) {
})
}
func exportStructToExcel[T any](data []T, headers []string, filename string, fns ...func(data []T, headers []string, f *excelize.File)) error {
func exportStructToExcel[T any](data []T, headers []string, filename string, exportType int, endTime string, fns ...func(data []T, headers []string, f *excelize.File)) error {
f := excelize.NewFile()
sheet := f.GetSheetName(f.GetActiveSheetIndex())
// 创建黄色背景样式(用于截止行)
yellowStyle, err := f.NewStyle(&excelize.Style{
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#FFFF00"}, // 黄色
Pattern: 1, // 实心填充
},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
})
if err != nil {
// 如果创建样式失败,继续执行但不应用样式
yellowStyle = 0
}
// 写入表头
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
@ -546,12 +564,52 @@ func exportStructToExcel[T any](data []T, headers []string, filename string, fns
}
// 写入数据
for rowIdx, item := range data {
actualRowIdx := 0
flag := 0
for _, item := range data {
val := reflect.ValueOf(item)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if exportType == e.BundlePurchaseExport {
if endTime != "" {
// 通过反射获取 PayTime 字段
payTimeField := val.FieldByName("PayTime")
if payTimeField.IsValid() && payTimeField.Kind() == reflect.String {
payTimeStr := payTimeField.String()
if payTimeStr != "" {
// 解析时间
endTimeParsed, err1 := time.Parse(time.DateTime, endTime)
payTimeParsed, err2 := time.Parse(time.DateTime, payTimeStr)
if err1 == nil && err2 == nil {
// 如果 endTime <= PayTime需要插入截止行
if !endTimeParsed.After(payTimeParsed) && flag == 0 {
flag = 1
// 格式化截止时间显示
endTimeFormatted := endTimeParsed.Format("2006年01月02日15点04分")
// 在当前行写入"截止xxxx年xx月xx点"
cell, _ := excelize.CoordinatesToCellName(1, actualRowIdx+2)
f.SetCellValue(sheet, cell, fmt.Sprintf("截止%s", endTimeFormatted))
// 应用黄色背景样式
if yellowStyle > 0 {
// 合并整行的单元格以显示截止信息
lastCol, _ := excelize.ColumnNumberToName(len(headers))
startCell := cell
endCell := fmt.Sprintf("%s%d", lastCol, actualRowIdx+2)
f.MergeCell(sheet, startCell, endCell)
f.SetCellStyle(sheet, startCell, endCell, yellowStyle)
}
// 移动到下一行
actualRowIdx++
}
}
}
}
}
}
for colIdx, fieldIdx := range exportedFields {
field := val.Field(fieldIdx)
@ -563,9 +621,10 @@ func exportStructToExcel[T any](data []T, headers []string, filename string, fns
cellValue = field.Interface()
}
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
cell, _ := excelize.CoordinatesToCellName(colIdx+1, actualRowIdx+2)
f.SetCellValue(sheet, cell, cellValue)
}
actualRowIdx++
}
}

View File

@ -0,0 +1,315 @@
package cast
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"fonchain-fiee/pkg/utils"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
)
const (
// Ayrshare API 配置
ayrshareAPIBaseURL = "https://api.ayrshare.com/api"
maxSmallFileSize = 30 * 1024 * 1024 // 30MB
apiKey = "208CBE9F-8E4F426A-990A0184-2C6287B9"
)
// UploadMediaResponse 上传媒体文件响应
type UploadMediaResponse struct {
ID string `json:"id"`
URL string `json:"url"`
FileName string `json:"fileName"`
Description string `json:"description"`
IsAs bool `json:"isAs"`
}
// UploadURLResponse 获取上传URL响应
type UploadURLResponse struct {
AccessURL string `json:"accessUrl"`
ContentType string `json:"contentType"`
UploadURL string `json:"uploadUrl"`
}
// VerifyURLResponse 验证URL响应
type VerifyURLResponse struct {
Status string `json:"status"`
StatusCode int `json:"statusCode"`
ContentType string `json:"contentType"`
}
// UploadMediaByURL 根据文件大小自动选择上传方式
// fileURL: 文件的URL链接
// apiKey: Ayrshare API Key
// fileName: 文件名(可选)
// description: 文件描述(可选)
func UploadMediaByURL(ctx context.Context, fileURL, fileName, description string) (*UploadMediaResponse, error) {
if fileURL == "" {
return nil, errors.New("文件URL不能为空")
}
ok, err := VerifyMediaURL(ctx, fileURL)
if err != nil {
return nil, err
}
if ok {
return &UploadMediaResponse{
ID: "",
URL: fileURL,
FileName: "",
Description: "",
IsAs: false,
}, nil
}
// 下载文件到临时目录
tempFile, fileSize, err := downloadFile(ctx, fileURL)
if err != nil {
return nil, fmt.Errorf("下载文件失败: %v", err)
}
defer os.Remove(tempFile) // 清理临时文件
zap.L().Info("文件下载完成", zap.String("tempFile", tempFile), zap.Int64("fileSize", fileSize))
// 如果没有提供文件名从URL中提取
if fileName == "" {
fileName = filepath.Base(fileURL)
}
// 根据文件大小选择上传方式
if fileSize < maxSmallFileSize {
zap.L().Info("使用小文件上传方式", zap.Int64("fileSize", fileSize))
return uploadSmallMedia(ctx, tempFile, apiKey, fileName, description)
}
zap.L().Info("使用大文件上传方式", zap.Int64("fileSize", fileSize))
return uploadLargeMedia(ctx, tempFile, apiKey, fileName, description)
}
// downloadFile 下载文件到临时目录
func downloadFile(ctx context.Context, fileURL string) (string, int64, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
if err != nil {
return "", 0, err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("下载文件失败HTTP状态码: %d", resp.StatusCode)
}
// 创建临时文件
tempFile, err := os.CreateTemp("", "ayrshare_upload_*")
if err != nil {
return "", 0, err
}
defer tempFile.Close()
// 复制内容到临时文件
fileSize, err := io.Copy(tempFile, resp.Body)
if err != nil {
os.Remove(tempFile.Name())
return "", 0, err
}
return tempFile.Name(), fileSize, nil
}
// uploadSmallMedia 上传小于30MB的文件使用multipart form-data
func uploadSmallMedia(ctx context.Context, filePath, apiKey, fileName, description string) (*UploadMediaResponse, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %v", err)
}
defer file.Close()
// 创建multipart form-data
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加文件
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return nil, err
}
if _, err := io.Copy(part, file); err != nil {
return nil, err
}
// 添加其他字段
if fileName != "" {
_ = writer.WriteField("fileName", fileName)
}
if description != "" {
_ = writer.WriteField("description", description)
}
if err := writer.Close(); err != nil {
return nil, err
}
// 发送请求
url := fmt.Sprintf("%s/media/upload", ayrshareAPIBaseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
zap.L().Error("上传小文件失败", zap.Int("statusCode", resp.StatusCode), zap.String("response", string(respBody)))
return nil, fmt.Errorf("上传失败: HTTP %d, %s", resp.StatusCode, string(respBody))
}
var result UploadMediaResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
zap.L().Info("小文件上传成功", zap.Any("response", result))
return &result, nil
}
// uploadLargeMedia 上传大于30MB的文件使用presigned URL
func uploadLargeMedia(ctx context.Context, filePath, apiKey, fileName, description string) (*UploadMediaResponse, error) {
// 获取文件的content type
ext := filepath.Ext(fileName)
if len(ext) > 0 {
ext = ext[1:] // 去掉点号
}
// Step 1: 获取上传URL
uploadURLResp, err := getUploadURL(ctx, apiKey, fileName, ext)
if err != nil {
return nil, fmt.Errorf("获取上传URL失败: %v", err)
}
zap.L().Info("获取上传URL成功", zap.Any("uploadURLResp", uploadURLResp))
// Step 2: 上传文件到presigned URL
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %v", err)
}
defer file.Close()
req, err := http.NewRequestWithContext(ctx, "PUT", uploadURLResp.UploadURL, file)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", uploadURLResp.ContentType)
client := &http.Client{Timeout: 30 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("上传文件失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
zap.L().Error("上传大文件失败", zap.Int("statusCode", resp.StatusCode), zap.String("response", string(respBody)))
return nil, fmt.Errorf("上传失败: HTTP %d", resp.StatusCode)
}
zap.L().Info("大文件上传成功")
// 返回结果
return &UploadMediaResponse{
URL: uploadURLResp.AccessURL,
FileName: fileName,
Description: description,
}, nil
}
// getUploadURL 获取大文件上传的presigned URL
func getUploadURL(ctx context.Context, apiKey, fileName, contentType string) (*UploadURLResponse, error) {
url := fmt.Sprintf("%s/media/uploadUrl?fileName=%s", ayrshareAPIBaseURL, fileName)
if contentType != "" {
url = fmt.Sprintf("%s&contentType=%s", url, contentType)
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
zap.L().Error("获取上传URL失败", zap.Int("statusCode", resp.StatusCode), zap.String("response", string(respBody)))
return nil, fmt.Errorf("获取上传URL失败: HTTP %d, %s", resp.StatusCode, string(respBody))
}
var result UploadURLResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
return &result, nil
}
// VerifyMediaURL 验证媒体URL是否有效
func VerifyMediaURL(ctx context.Context, mediaURL string) (bool, error) {
url := fmt.Sprintf("%s/media/urlExists", ayrshareAPIBaseURL)
requestBody := map[string]string{
"mediaUrl": mediaURL,
}
jsonData, _ := json.Marshal(requestBody)
code, postBody, err := utils.PostBytesHeader(url, map[string]interface{}{
"Content-Type": "application/json",
"Authorization": "Bearer " + apiKey,
}, jsonData)
if err != nil {
zap.L().Error("VerifyMediaURL 提交异常", zap.Error(err))
return false, errors.New("验证链接提交异常")
}
if code != http.StatusOK {
return false, nil
}
var result *VerifyURLResponse
_ = json.Unmarshal(postBody, &result)
if result.StatusCode != http.StatusOK || result.Status != "success" {
return false, nil
}
return true, nil
}

View File

@ -99,6 +99,26 @@ func Test(ctx *gin.Context) {
service.Success(ctx, resp)
return
}
if action == "upload" {
fileUrl := ctx.PostForm("fileUrl")
uploadResp, err := UploadMediaByURL(ctx, fileUrl, "", "")
if err != nil {
service.Error(ctx, err)
return
}
service.Success(ctx, uploadResp)
return
}
if action == "getUrl" {
fileUrl := ctx.PostForm("fileUrl")
uploadResp, err := VerifyMediaURL(ctx, fileUrl)
if err != nil {
service.Error(ctx, err)
return
}
service.Success(ctx, uploadResp)
return
}
service.Success(ctx, "unknow")
return
}

View File

@ -452,11 +452,37 @@ func PostAS(workUuids []string) (errs []error) {
var isVideo bool
if workDetail.WorkCategory == 1 {
isVideo = false
mediaUrls = workDetail.Images
for _, imageUrl := range workDetail.Images {
urlResp, err := UploadMediaByURL(context.Background(), imageUrl, "", "")
if err != nil {
zap.L().Error("Publish UploadMediaByURL failed", zap.String("imageUrl", imageUrl), zap.Error(err))
continue
}
mediaUrls = append(mediaUrls, urlResp.URL)
}
//mediaUrls = workDetail.Images
}
var coverUrl string
if workDetail.WorkCategory == 2 {
isVideo = true
mediaUrls = []string{workDetail.VideoUrl}
//mediaUrls = []string{workDetail.VideoUrl}
urlResp, err := UploadMediaByURL(context.Background(), workDetail.VideoUrl, "", "")
if err != nil {
zap.L().Error("Publish UploadMediaByURL failed", zap.String("VideoUrl", workDetail.VideoUrl), zap.Error(err))
continue
}
mediaUrls = []string{urlResp.URL}
if workDetail.CoverUrl != "" {
urlResp, err = UploadMediaByURL(context.Background(), workDetail.VideoUrl, "", "")
if err != nil {
zap.L().Error("Publish UploadMediaByURL failed", zap.String("VideoUrl", workDetail.VideoUrl), zap.Error(err))
continue
}
coverUrl = urlResp.URL
}
}
if len(mediaUrls) == 0 {
continue
}
ArtistInfoResp, _err := service.CastProvider.GetArtist(context.Background(), &cast.GetArtistReq{
ArtistUuid: workDetail.ArtistUuid,
@ -469,7 +495,6 @@ func PostAS(workUuids []string) (errs []error) {
errs = append(errs, errors.New("艺人平台信息未配置"))
continue
}
for _, platformID := range needPlatformIDs {
var postResp *aryshare.PostResponse = &aryshare.PostResponse{}
postReq := &aryshare.PostRequest{
@ -488,7 +513,7 @@ func PostAS(workUuids []string) (errs []error) {
postReq.InstagramOptions = &aryshare.InstagramOptions{
ShareReelsFeed: false,
AudioName: "",
ThumbNail: workDetail.CoverUrl,
ThumbNail: coverUrl,
ThumbNailOffset: 0,
Stories: false,
AltText: nil,

44
pkg/utils/files.go Normal file
View File

@ -0,0 +1,44 @@
package utils
import (
"errors"
"fmt"
"fonchain-fiee/pkg/e"
"io"
"net/http"
"os"
"strings"
"go.uber.org/zap"
)
// SaveUrlFileDisk 保存图片到本地
func SaveUrlFileDisk(url string, path string, filename string) (fullPath string, err error) {
if err = CreateDirPath(path); err != nil {
zap.L().Error("SaveUrlFileDisk err ", zap.Error(err))
return
}
if filename == "" {
stepName := strings.Split(url, "/")
if len(stepName) > 1 {
filename = stepName[len(stepName)-1]
}
}
resp, err := http.Get(url)
if err != nil {
err = errors.New(e.GetMsg(e.ERROR_DOWNLOAD_FILE))
return
}
defer func() {
if err := recover(); err != nil {
}
resp.Body.Close()
}()
bytes, err := io.ReadAll(resp.Body)
fullPath = fmt.Sprintf("%s/%s", path, filename)
// 写入数据
err = os.WriteFile(fullPath, bytes, 0777)
return
}

View File

@ -133,3 +133,24 @@ func GetUrl(apiUrl string, headerData map[string]string, proxyURL ...string) (st
zap.L().Info("Get", zap.Any("url", apiUrl), zap.Any("body", body))
return
}
func PostBytesHeader(url string, header map[string]interface{}, data []byte) (int, []byte, error) {
req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
if err != nil {
return 0, nil, fmt.Errorf("创建请求失败: %v", "")
}
for k, v := range header {
req.Header.Set(k, fmt.Sprintf("%v", v))
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("请求失败: %v", "")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, nil, fmt.Errorf("读取响应失败: %v", "")
}
return resp.StatusCode, body, nil
}

View File

@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"time"
@ -163,3 +164,11 @@ func CopyFile(src, dstDir string) (string, error) {
}
return dst, nil
}
func FileNameFromURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
return path.Base(u.Path), nil
}