From 557a641f4197916b459a9d650da5d8c150252af9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 19 Dec 2025 19:05:12 +0800 Subject: [PATCH] feat: migrate serevices --- .../app/events/publishers/user_register.go | 1 + backend_v1/app/jobs/balance_pay_notify.go | 109 +++ backend_v1/app/jobs/demo_cron.go | 36 - backend_v1/app/jobs/demo_job.go | 53 -- backend_v1/app/jobs/demo_job_test.go | 53 -- backend_v1/app/jobs/download_from_alioss.go | 104 +++ .../app/jobs/download_from_alioss_test.go | 63 ++ backend_v1/app/jobs/provider.gen.go | 101 ++- backend_v1/app/jobs/publish_draft_posts.go | 105 +++ backend_v1/app/jobs/remove_file.go | 61 ++ backend_v1/app/jobs/video_cut.go | 94 +++ .../app/jobs/video_extract_head_image.go | 128 ++++ backend_v1/app/jobs/video_store_short.go | 135 ++++ backend_v1/app/services/medias.go | 88 +++ backend_v1/app/services/orders.go | 133 ++++ backend_v1/app/services/posts.go | 150 ++++ backend_v1/app/services/provider.gen.go | 32 +- backend_v1/app/services/services.gen.go | 9 +- backend_v1/app/services/test.go | 10 - backend_v1/app/services/test_test.go | 41 - backend_v1/database/.transform.yaml | 35 +- backend_v1/database/models/medias.gen.go | 18 +- backend_v1/database/models/orders.gen.go | 32 +- .../database/models/orders.query.gen.go | 6 +- backend_v1/database/models/post.go | 5 + backend_v1/database/models/posts.gen.go | 32 +- backend_v1/database/models/posts.query.gen.go | 6 +- backend_v1/database/models/users.gen.go | 24 +- backend_v1/database/models/users.query.gen.go | 6 +- backend_v1/go.mod | 22 +- backend_v1/go.sum | 63 ++ backend_v1/pkg/fields/medias.go | 7 + backend_v1/pkg/fields/orders.gen.go | 271 +++++++ backend_v1/pkg/fields/orders.go | 16 + backend_v1/pkg/fields/posts.gen.go | 467 ++++++++++++ backend_v1/pkg/fields/posts.go | 16 + backend_v1/pkg/fields/users.gen.go | 241 ++++++ backend_v1/pkg/fields/users.go | 27 + backend_v1/pkg/oauth/contracts.go | 12 + backend_v1/pkg/oauth/wechat.go | 40 + backend_v1/pkg/utils/exec.go | 69 ++ backend_v1/pkg/utils/ffmpeg.go | 62 ++ backend_v1/pkg/utils/ffmpeg_test.go | 49 ++ backend_v1/pkg/utils/fiber.go | 19 + backend_v1/pkg/utils/md5.go | 42 ++ backend_v1/pkg/utils/posts.go | 45 ++ backend_v1/pkg/utils/random_name.go | 25 + backend_v1/pkg/utils/random_name_test.go | 11 + backend_v1/providers/ali/config.go | 56 ++ backend_v1/providers/ali/oss_client.go | 165 ++++ backend_v1/providers/app/config.go | 10 +- backend_v1/providers/app/config.go.bak | 45 ++ backend_v1/providers/req/client.go | 152 ++++ backend_v1/providers/req/config.go | 34 + backend_v1/providers/req/cookiejar/jar.go | 704 ++++++++++++++++++ .../providers/req/cookiejar/punycode.go | 159 ++++ .../providers/req/cookiejar/serialize.go | 188 +++++ .../providers/wechat/certs/apiclient_cert.p12 | Bin 0 -> 2782 bytes .../providers/wechat/certs/apiclient_cert.pem | 25 + .../providers/wechat/certs/apiclient_key.pem | 28 + backend_v1/providers/wechat/config.go | 59 ++ backend_v1/providers/wechat/errors.go | 59 ++ backend_v1/providers/wechat/funcs.go | 24 + backend_v1/providers/wechat/options.go | 76 ++ backend_v1/providers/wechat/response.go | 17 + backend_v1/providers/wechat/wechat.go | 339 +++++++++ backend_v1/providers/wechat/wechat_test.go | 107 +++ backend_v1/providers/wepay/config.go | 58 ++ backend_v1/providers/wepay/pay.go | 330 ++++++++ backend_v1/providers/wepay/pay_test.go | 76 ++ curl.demo | 21 + 71 files changed, 5626 insertions(+), 280 deletions(-) create mode 100644 backend_v1/app/jobs/balance_pay_notify.go delete mode 100644 backend_v1/app/jobs/demo_cron.go delete mode 100644 backend_v1/app/jobs/demo_job.go delete mode 100644 backend_v1/app/jobs/demo_job_test.go create mode 100644 backend_v1/app/jobs/download_from_alioss.go create mode 100644 backend_v1/app/jobs/download_from_alioss_test.go create mode 100644 backend_v1/app/jobs/publish_draft_posts.go create mode 100644 backend_v1/app/jobs/remove_file.go create mode 100644 backend_v1/app/jobs/video_cut.go create mode 100644 backend_v1/app/jobs/video_extract_head_image.go create mode 100644 backend_v1/app/jobs/video_store_short.go create mode 100644 backend_v1/app/services/medias.go create mode 100644 backend_v1/app/services/orders.go create mode 100644 backend_v1/app/services/posts.go delete mode 100644 backend_v1/app/services/test.go delete mode 100644 backend_v1/app/services/test_test.go create mode 100644 backend_v1/database/models/post.go create mode 100644 backend_v1/pkg/fields/medias.go create mode 100644 backend_v1/pkg/fields/orders.gen.go create mode 100644 backend_v1/pkg/fields/orders.go create mode 100644 backend_v1/pkg/fields/posts.gen.go create mode 100644 backend_v1/pkg/fields/posts.go create mode 100644 backend_v1/pkg/fields/users.gen.go create mode 100644 backend_v1/pkg/fields/users.go create mode 100644 backend_v1/pkg/oauth/contracts.go create mode 100644 backend_v1/pkg/oauth/wechat.go create mode 100644 backend_v1/pkg/utils/exec.go create mode 100644 backend_v1/pkg/utils/ffmpeg.go create mode 100644 backend_v1/pkg/utils/ffmpeg_test.go create mode 100644 backend_v1/pkg/utils/fiber.go create mode 100644 backend_v1/pkg/utils/md5.go create mode 100644 backend_v1/pkg/utils/posts.go create mode 100644 backend_v1/pkg/utils/random_name.go create mode 100644 backend_v1/pkg/utils/random_name_test.go create mode 100644 backend_v1/providers/ali/config.go create mode 100644 backend_v1/providers/ali/oss_client.go create mode 100644 backend_v1/providers/app/config.go.bak create mode 100644 backend_v1/providers/req/client.go create mode 100644 backend_v1/providers/req/config.go create mode 100644 backend_v1/providers/req/cookiejar/jar.go create mode 100644 backend_v1/providers/req/cookiejar/punycode.go create mode 100644 backend_v1/providers/req/cookiejar/serialize.go create mode 100644 backend_v1/providers/wechat/certs/apiclient_cert.p12 create mode 100644 backend_v1/providers/wechat/certs/apiclient_cert.pem create mode 100644 backend_v1/providers/wechat/certs/apiclient_key.pem create mode 100644 backend_v1/providers/wechat/config.go create mode 100644 backend_v1/providers/wechat/errors.go create mode 100644 backend_v1/providers/wechat/funcs.go create mode 100644 backend_v1/providers/wechat/options.go create mode 100644 backend_v1/providers/wechat/response.go create mode 100644 backend_v1/providers/wechat/wechat.go create mode 100644 backend_v1/providers/wechat/wechat_test.go create mode 100644 backend_v1/providers/wepay/config.go create mode 100644 backend_v1/providers/wepay/pay.go create mode 100644 backend_v1/providers/wepay/pay_test.go create mode 100644 curl.demo diff --git a/backend/app/events/publishers/user_register.go b/backend/app/events/publishers/user_register.go index 8314cdf..32fc555 100644 --- a/backend/app/events/publishers/user_register.go +++ b/backend/app/events/publishers/user_register.go @@ -11,6 +11,7 @@ import ( var _ contracts.EventPublisher = (*UserRegister)(nil) type UserRegister struct { + event.DefaultChannel ID int64 `json:"id"` } diff --git a/backend_v1/app/jobs/balance_pay_notify.go b/backend_v1/app/jobs/balance_pay_notify.go new file mode 100644 index 0000000..84e62b4 --- /dev/null +++ b/backend_v1/app/jobs/balance_pay_notify.go @@ -0,0 +1,109 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*BalancePayNotify)(nil) + +type BalancePayNotify struct { + OrderNo string `json:"order_no"` +} + +func (s BalancePayNotify) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (BalancePayNotify) Kind() string { return "balance_pay_notify" } +func (a BalancePayNotify) UniqueID() string { return a.Kind() } + +var _ Worker[BalancePayNotify] = (*BalancePayNotifyWorker)(nil) + +// @provider(job) +type BalancePayNotifyWorker struct { + WorkerDefaults[BalancePayNotify] +} + +func (w *BalancePayNotifyWorker) Work(ctx context.Context, job *Job[BalancePayNotify]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + order, err := model.OrdersModel().GetByOrderNo(context.Background(), job.Args.OrderNo) + if err != nil { + log.Errorf("GetByOrderNo error:%v", err) + return err + } + + if order.Status != fields.OrderStatusPending { + log.Infof("Order %s is paid, processing...", job.Args.OrderNo) + return JobCancel(fmt.Errorf("Order already paid, currently status: %d", order.Status)) + } + + user, err := model.UsersModel().GetByID(context.Background(), order.UserID) + if err != nil { + log.Errorf("GetByID error:%v", err) + return errors.Wrap(err, "get user error") + } + + payPrice := order.Price * int64(order.Discount) / 100 + + order.PaymentMethod = "balance" + order.Status = fields.OrderStatusCompleted + + meta := order.Meta.Data + + if user.Balance-meta.CostBalance < 0 { + log.Errorf("User %d balance is not enough, current balance: %d, cost: %d", user.ID, user.Balance, payPrice) + return JobCancel( + fmt.Errorf("User %d balance is not enough, current balance: %d, cost: %d", user.ID, user.Balance, payPrice), + ) + } + + log.Infof("Updated order details: %+v", order) + tx, err := model.Transaction(ctx) + if err != nil { + return errors.Wrap(err, "Transaction error") + } + defer tx.Rollback() + + // update user balance + err = user.SetBalance(ctx, user.Balance-payPrice) + if err != nil { + log.WithError(err).Error("SetBalance error") + return JobCancel(errors.Wrap(err, "set user balance failed")) + } + + if err := user.BuyPosts(context.Background(), order.PostID, order.Price); err != nil { + log.Errorf("BuyPosts error:%v", err) + return errors.Wrap(err, "BuyPosts error") + } + + if err := order.Update(context.Background()); err != nil { + log.Errorf("Update order error:%v", err) + return errors.Wrap(err, "Update order error") + } + if err := tx.Commit(); err != nil { + log.Errorf("Commit error:%v", err) + return errors.Wrap(err, "Commit error") + } + + log.Infof("Successfully processed order %s", order.OrderNo) + return nil +} + +func (w *BalancePayNotifyWorker) NextRetry(job *Job[BalancePayNotify]) time.Time { + return time.Now().Add(30 * time.Second) +} diff --git a/backend_v1/app/jobs/demo_cron.go b/backend_v1/app/jobs/demo_cron.go deleted file mode 100644 index 88960d4..0000000 --- a/backend_v1/app/jobs/demo_cron.go +++ /dev/null @@ -1,36 +0,0 @@ -package jobs - -import ( - "time" - - . "github.com/riverqueue/river" - "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" -) - -var _ contracts.CronJob = (*DemoCronJob)(nil) - -// @provider(cronjob) -type DemoCronJob struct { - log *logrus.Entry `inject:"false"` -} - -// Prepare implements contracts.CronJob. -func (DemoCronJob) Prepare() error { - return nil -} - -// JobArgs implements contracts.CronJob. -func (DemoCronJob) Args() []contracts.CronJobArg { - return []contracts.CronJobArg{ - { - Arg: DemoJob{ - Strings: []string{"a", "b", "c", "d"}, - }, - - PeriodicInterval: PeriodicInterval(time.Second * 10), - RunOnStart: false, - }, - } -} diff --git a/backend_v1/app/jobs/demo_job.go b/backend_v1/app/jobs/demo_job.go deleted file mode 100644 index e36dab8..0000000 --- a/backend_v1/app/jobs/demo_job.go +++ /dev/null @@ -1,53 +0,0 @@ -package jobs - -import ( - "context" - "sort" - "time" - - . "github.com/riverqueue/river" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - _ "go.ipao.vip/atom/contracts" -) - -var _ contracts.JobArgs = DemoJob{} - -type DemoJob struct { - Strings []string `json:"strings"` -} - -func (s DemoJob) InsertOpts() InsertOpts { - return InsertOpts{ - Queue: QueueDefault, - Priority: PriorityDefault, - } -} - -func (DemoJob) Kind() string { return "demo_job" } -func (a DemoJob) UniqueID() string { return a.Kind() } - -var _ Worker[DemoJob] = (*DemoJobWorker)(nil) - -// @provider(job) -type DemoJobWorker struct { - WorkerDefaults[DemoJob] -} - -func (w *DemoJobWorker) NextRetry(job *Job[DemoJob]) time.Time { - return time.Now().Add(30 * time.Second) -} - -func (w *DemoJobWorker) Work(ctx context.Context, job *Job[DemoJob]) error { - logger := log.WithField("job", job.Args.Kind()) - - logger.Infof("[START] %s args: %v", job.Args.Kind(), job.Args.Strings) - defer logger.Infof("[END] %s", job.Args.Kind()) - - // modify below - sort.Strings(job.Args.Strings) - logger.Infof("[%s] Sorted strings: %v\n", time.Now().Format(time.TimeOnly), job.Args.Strings) - - return nil -} diff --git a/backend_v1/app/jobs/demo_job_test.go b/backend_v1/app/jobs/demo_job_test.go deleted file mode 100644 index 6f08455..0000000 --- a/backend_v1/app/jobs/demo_job_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package jobs - -import ( - "context" - "testing" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/services" - - . "github.com/riverqueue/river" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.uber.org/dig" -) - -type DemoJobSuiteInjectParams struct { - dig.In - - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type DemoJobSuite struct { - suite.Suite - - DemoJobSuiteInjectParams -} - -func Test_DemoJob(t *testing.T) { - providers := testx.Default().With(Provide, services.Provide) - - testx.Serve(providers, t, func(p DemoJobSuiteInjectParams) { - suite.Run(t, &DemoJobSuite{DemoJobSuiteInjectParams: p}) - }) -} - -func (t *DemoJobSuite) Test_Work() { - Convey("test_work", t.T(), func() { - Convey("step 1", func() { - job := &Job[DemoJob]{ - Args: DemoJob{ - Strings: []string{"a", "b", "c"}, - }, - } - - worker := &DemoJobWorker{} - - err := worker.Work(context.Background(), job) - So(err, ShouldBeNil) - }) - }) -} diff --git a/backend_v1/app/jobs/download_from_alioss.go b/backend_v1/app/jobs/download_from_alioss.go new file mode 100644 index 0000000..1ff6cd9 --- /dev/null +++ b/backend_v1/app/jobs/download_from_alioss.go @@ -0,0 +1,104 @@ +package jobs + +import ( + "context" + "os" + "path/filepath" + "time" + + "quyun/v2/app/model" + "quyun/v2/providers/ali" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" + _ "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*DownloadFromAliOSS)(nil) + +type DownloadFromAliOSS struct { + MediaHash string `json:"media_hash"` +} + +func (s DownloadFromAliOSS) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (s DownloadFromAliOSS) Kind() string { return "download_from_ali_oss" } +func (a DownloadFromAliOSS) UniqueID() string { return a.Kind() } + +var _ Worker[DownloadFromAliOSS] = (*DownloadFromAliOSSWorker)(nil) + +// @provider(job) +type DownloadFromAliOSSWorker struct { + WorkerDefaults[DownloadFromAliOSS] + + oss *ali.OSSClient + job *job.Job + app *app.Config +} + +func (w *DownloadFromAliOSSWorker) NextRetry(job *Job[DownloadFromAliOSS]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *DownloadFromAliOSSWorker) Work(ctx context.Context, job *Job[DownloadFromAliOSS]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + media, err := model.MediasModel().GetByHash(ctx, job.Args.MediaHash) + if err != nil { + log.Errorf("Error getting media by ID: %v", err) + return JobCancel(err) + } + + dst := filepath.Join(w.app.StoragePath, media.Path) + // check is path exist + st, err := os.Stat(dst) + if os.IsNotExist(err) { + log.Infof("File not exists: %s", dst) + err := os.MkdirAll(filepath.Dir(dst), os.ModePerm) + if err != nil { + log.Errorf("Error creating directory: %v", err) + return err + } + } else { + if st.Size() == media.Size { + return w.NextJob(media.Hash) + } else { + // remove file + if err := os.Remove(dst); err != nil { + log.Errorf("Error removing file: %v", err) + return err + } + } + } + + log.Infof("Starting download for file: %s", media.Path) + if err := w.oss.Download(ctx, media.Path, dst, ali.WithInternal()); err != nil { + log.Errorf("Error downloading file: %v", err) + return err + } + + log.Infof("Successfully downloaded file: %s", media.Path) + + return w.NextJob(media.Hash) +} + +func (w *DownloadFromAliOSSWorker) NextJob(hash string) error { + if err := w.job.Add(&VideoCut{MediaHash: hash}); err != nil { + log.Errorf("Error adding job: %v", err) + return err + } + + return nil +} diff --git a/backend_v1/app/jobs/download_from_alioss_test.go b/backend_v1/app/jobs/download_from_alioss_test.go new file mode 100644 index 0000000..26b08e9 --- /dev/null +++ b/backend_v1/app/jobs/download_from_alioss_test.go @@ -0,0 +1,63 @@ +package jobs + +import ( + "context" + "testing" + + "quyun/v2/app/commands/testx" + "quyun/v2/app/model" + "quyun/v2/providers/ali" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + . "github.com/riverqueue/river" + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/suite" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" + "go.uber.org/dig" +) + +type DownloadFromAliOSSSuiteInjectParams struct { + dig.In + + Initials []contracts.Initial `group:"initials"` // nolint:structcheck + Job *job.Job + Oss *ali.OSSClient + App *app.Config +} + +type DownloadFromAliOSSSuite struct { + suite.Suite + + DownloadFromAliOSSSuiteInjectParams +} + +func Test_DownloadFromAliOSS(t *testing.T) { + providers := testx.Default().With(Provide, model.Provide) + + testx.Serve(providers, t, func(p DownloadFromAliOSSSuiteInjectParams) { + suite.Run(t, &DownloadFromAliOSSSuite{DownloadFromAliOSSSuiteInjectParams: p}) + }) +} + +func (t *DownloadFromAliOSSSuite) Test_Work() { + Convey("test_work", t.T(), func() { + Convey("step 1", func() { + job := &Job[DownloadFromAliOSS]{ + Args: DownloadFromAliOSS{ + MediaHash: "959e5310105c96e653f10b74e5bdc36b", + }, + } + + worker := &DownloadFromAliOSSWorker{ + oss: t.Oss, + job: t.Job, + app: t.App, + } + + err := worker.Work(context.Background(), job) + So(err, ShouldBeNil) + }) + }) +} diff --git a/backend_v1/app/jobs/provider.gen.go b/backend_v1/app/jobs/provider.gen.go index 95331e5..57c2170 100755 --- a/backend_v1/app/jobs/provider.gen.go +++ b/backend_v1/app/jobs/provider.gen.go @@ -1,6 +1,8 @@ package jobs import ( + "quyun/v2/providers/ali" + "quyun/v2/providers/app" "quyun/v2/providers/job" "github.com/riverqueue/river" @@ -14,12 +16,48 @@ func Provide(opts ...opt.Option) error { if err := container.Container.Provide(func( __job *job.Job, ) (contracts.Initial, error) { - obj := &DemoCronJob{} - if err := obj.Prepare(); err != nil { + obj := &BalancePayNotifyWorker{} + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { return nil, err } - container.Later(func() error { return __job.AddPeriodicJobs(obj) }) + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + app *app.Config, + job *job.Job, + oss *ali.OSSClient, + ) (contracts.Initial, error) { + obj := &DownloadFromAliOSSWorker{ + app: app, + job: job, + oss: oss, + } + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + app *app.Config, + job *job.Job, + oss *ali.OSSClient, + ) (contracts.Initial, error) { + obj := &PublishDraftPostsWorker{ + app: app, + job: job, + oss: oss, + } + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } return obj, nil }, atom.GroupInitial); err != nil { @@ -28,7 +66,62 @@ func Provide(opts ...opt.Option) error { if err := container.Container.Provide(func( __job *job.Job, ) (contracts.Initial, error) { - obj := &DemoJobWorker{} + obj := &RemoveFileWorker{} + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + app *app.Config, + job *job.Job, + ) (contracts.Initial, error) { + obj := &VideoCutWorker{ + app: app, + job: job, + } + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + app *app.Config, + job *job.Job, + oss *ali.OSSClient, + ) (contracts.Initial, error) { + obj := &VideoExtractHeadImageWorker{ + app: app, + job: job, + oss: oss, + } + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + app *app.Config, + job *job.Job, + oss *ali.OSSClient, + ) (contracts.Initial, error) { + obj := &VideoStoreShortWorker{ + app: app, + job: job, + oss: oss, + } if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { return nil, err } diff --git a/backend_v1/app/jobs/publish_draft_posts.go b/backend_v1/app/jobs/publish_draft_posts.go new file mode 100644 index 0000000..42c522f --- /dev/null +++ b/backend_v1/app/jobs/publish_draft_posts.go @@ -0,0 +1,105 @@ +package jobs + +import ( + "context" + "time" + + "quyun/v2/pkg/utils" + "quyun/v2/providers/ali" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + "github.com/samber/lo" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*PublishDraftPosts)(nil) + +type PublishDraftPosts struct { + MediaHash string `json:"media_hash"` +} + +func (s PublishDraftPosts) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (s PublishDraftPosts) Kind() string { return "publish_draft_posts" } +func (a PublishDraftPosts) UniqueID() string { return a.Kind() } + +var _ Worker[PublishDraftPosts] = (*PublishDraftPostsWorker)(nil) + +// @provider(job) +type PublishDraftPostsWorker struct { + WorkerDefaults[PublishDraftPosts] + + oss *ali.OSSClient + job *job.Job + app *app.Config +} + +func (w *PublishDraftPostsWorker) NextRetry(job *Job[PublishDraftPosts]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *PublishDraftPostsWorker) Work(ctx context.Context, job *Job[PublishDraftPosts]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + media, err := model.MediasModel().GetByHash(ctx, job.Args.MediaHash) + if err != nil { + log.Errorf("Error getting media by ID: %v", err) + return JobCancel(err) + } + + relationMedias, err := model.MediasModel().GetRelations(ctx, media.Hash) + if err != nil { + log.Errorf("Error getting relation medias: %v", err) + return JobCancel(err) + } + + assets := lo.FilterMap(relationMedias, func(media *model.Medias, _ int) (fields.MediaAsset, bool) { + return fields.MediaAsset{ + Type: media.MimeType, + Media: media.ID, + Metas: &media.Metas.Data, + }, media.MimeType != "image/jpeg" + }) + assets = append(assets, fields.MediaAsset{ + Type: media.MimeType, + Media: media.ID, + Metas: &media.Metas.Data, + }) + + // publish a draft posts + post := &model.Posts{ + Status: fields.PostStatusDraft, + Title: utils.FormatTitle(media.Name), + Description: "", + Content: "", + Price: 0, + Discount: 100, + Views: 0, + Likes: 0, + Tags: fields.Json[[]string]{}, + Assets: fields.ToJson(assets), + HeadImages: fields.ToJson(lo.FilterMap(relationMedias, func(media *model.Medias, _ int) (int64, bool) { + return media.ID, media.MimeType == "image/jpeg" + })), + } + if err := post.Create(ctx); err != nil { + log.Errorf("Error creating post: %v", err) + return errors.Wrap(err, "create post") + } + log.Infof("Post created successfully with ID: %d", post.ID) + + return nil +} diff --git a/backend_v1/app/jobs/remove_file.go b/backend_v1/app/jobs/remove_file.go new file mode 100644 index 0000000..5e1ff6a --- /dev/null +++ b/backend_v1/app/jobs/remove_file.go @@ -0,0 +1,61 @@ +package jobs + +import ( + "context" + "os" + "time" + + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*RemoveFile)(nil) + +type RemoveFile struct { + FilePath string `json:"file_path"` +} + +func (s RemoveFile) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + // ScheduledAt: time.Now().Add(time.Minute * 10), + } +} + +func (s RemoveFile) Kind() string { return "remove_file" } +func (a RemoveFile) UniqueID() string { return a.Kind() } + +var _ Worker[RemoveFile] = (*RemoveFileWorker)(nil) + +// @provider(job) +type RemoveFileWorker struct { + WorkerDefaults[RemoveFile] +} + +func (w *RemoveFileWorker) NextRetry(job *Job[RemoveFile]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *RemoveFileWorker) Work(ctx context.Context, job *Job[RemoveFile]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + // Check if the file exists + if _, err := os.Stat(job.Args.FilePath); os.IsNotExist(err) { + log.Warnf("File does not exist: %v", job.Args.FilePath) + return nil + } + // Remove the file + if err := os.Remove(job.Args.FilePath); err != nil { + log.Errorf("Error removing file: %v", err) + return err + } + log.Infof("File removed successfully: %v", job.Args.FilePath) + + return nil +} diff --git a/backend_v1/app/jobs/video_cut.go b/backend_v1/app/jobs/video_cut.go new file mode 100644 index 0000000..44cc45b --- /dev/null +++ b/backend_v1/app/jobs/video_cut.go @@ -0,0 +1,94 @@ +package jobs + +import ( + "context" + "path/filepath" + "time" + + "quyun/v2/app/model" + "quyun/v2/database/fields" + "quyun/v2/pkg/utils" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*VideoCut)(nil) + +type VideoCut struct { + MediaHash string `json:"media_hash"` +} + +func (s VideoCut) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (s VideoCut) Kind() string { return "video_cut" } +func (a VideoCut) UniqueID() string { return a.Kind() } + +var _ Worker[VideoCut] = (*VideoCutWorker)(nil) + +// @provider(job) +type VideoCutWorker struct { + WorkerDefaults[VideoCut] + + job *job.Job + app *app.Config +} + +func (w *VideoCutWorker) NextRetry(job *Job[VideoCut]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *VideoCutWorker) Work(ctx context.Context, job *Job[VideoCut]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + media, err := model.MediasModel().GetByHash(ctx, job.Args.MediaHash) + if err != nil { + log.Errorf("Error getting media by ID: %v", err) + return JobCancel(err) + } + + input := filepath.Join(w.app.StoragePath, media.Path) + output := input[:len(input)-len(filepath.Ext(input))] + "-short" + filepath.Ext(input) + + log.Infof("cut video process %s to %s", input, output) + + if err := utils.CutMedia(input, output, 0, 60); err != nil { + log.Errorf("Error cutting media: %v", err) + return errors.Wrap(err, "cut media") + } + + duration, err := utils.GetMediaDuration(input) + if err != nil { + log.Errorf("Error getting media duration: %v", err) + return errors.Wrap(err, "get media duration") + } + // update media metas + metas := fields.MediaMetas{ + ParentHash: "", + Short: false, + Duration: duration, + } + if err := model.MediasModel().UpdateMetas(ctx, media.ID, metas); err != nil { + log.Errorf("Error updating media metas: %v", err) + return errors.Wrap(err, "update media metas") + } + + // save to database + return w.job.Add(&VideoStoreShort{ + MediaHash: media.Hash, + FilePath: output, + }) +} diff --git a/backend_v1/app/jobs/video_extract_head_image.go b/backend_v1/app/jobs/video_extract_head_image.go new file mode 100644 index 0000000..212196b --- /dev/null +++ b/backend_v1/app/jobs/video_extract_head_image.go @@ -0,0 +1,128 @@ +package jobs + +import ( + "context" + "os" + "path/filepath" + "time" + + "quyun/v2/app/model" + "quyun/v2/database/fields" + "quyun/v2/pkg/utils" + "quyun/v2/providers/ali" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*VideoExtractHeadImage)(nil) + +type VideoExtractHeadImage struct { + MediaHash string `json:"media_hash"` +} + +func (s VideoExtractHeadImage) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (s VideoExtractHeadImage) Kind() string { return "video_extract_head_image" } +func (a VideoExtractHeadImage) UniqueID() string { return a.Kind() } + +var _ Worker[VideoExtractHeadImage] = (*VideoExtractHeadImageWorker)(nil) + +// @provider(job) +type VideoExtractHeadImageWorker struct { + WorkerDefaults[VideoExtractHeadImage] + + oss *ali.OSSClient + job *job.Job + app *app.Config +} + +func (w *VideoExtractHeadImageWorker) NextRetry(job *Job[VideoExtractHeadImage]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *VideoExtractHeadImageWorker) Work(ctx context.Context, job *Job[VideoExtractHeadImage]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + media, err := model.MediasModel().GetByHash(ctx, job.Args.MediaHash) + if err != nil { + log.Errorf("Error getting media by ID: %v", err) + return JobCancel(err) + } + + input := filepath.Join(w.app.StoragePath, media.Path) + output := input[:len(input)-len(filepath.Ext(input))] + ".jpg" + + if err := utils.GetFrameImageFromVideo(input, output, 1); err != nil { + log.Errorf("Error extracting image from video: %v", err) + return errors.Wrap(err, "failed to extract image from video") + } + defer os.RemoveAll(output) + + fileSize, err := utils.GetFileSize(output) + if err != nil { + log.Errorf("Error getting file size: %v", err) + return errors.Wrap(err, "failed to get file size") + } + + fileMd5, err := utils.GetFileMd5(output) + if err != nil { + log.Errorf("Error getting file MD5: %v", err) + return errors.Wrap(err, "failed to get file MD5") + } + filename := fileMd5 + filepath.Ext(output) + + name := "[展示图]" + media.Name + ".jpg" + + // create a new media record for the image + imageMedia := &model.Medias{ + Name: name, + MimeType: "image/jpeg", + Size: fileSize, + Path: w.oss.GetSavePath(filename), + Hash: fileMd5, + Metas: fields.ToJson(fields.MediaMetas{ + ParentHash: media.Hash, + }), + } + + // upload to oss + if err := w.oss.Upload(ctx, output, imageMedia.Path, ali.WithInternal()); err != nil { + log.Errorf("Error uploading image to OSS: %v", err) + return errors.Wrap(err, "failed to upload image to OSS") + } + + if err := w.job.Add(&RemoveFile{FilePath: output}); err != nil { + log.Errorf("Error removing original file: %v", err) + } + + if err := imageMedia.Create(ctx); err != nil { + log.Errorf("Error creating media record: %v", err) + return errors.Wrap(err, "failed to create media record") + } + + dst := filepath.Join(w.app.StoragePath, media.Path) + if err := w.job.Add(&RemoveFile{FilePath: dst}); err != nil { + log.Errorf("Error removing original file: %v", err) + } + + if err := w.job.Add(&PublishDraftPosts{MediaHash: media.Hash}); err != nil { + log.Errorf("Error adding job: %v", err) + return errors.Wrap(err, "failed to add job") + } + + return nil +} diff --git a/backend_v1/app/jobs/video_store_short.go b/backend_v1/app/jobs/video_store_short.go new file mode 100644 index 0000000..8e02003 --- /dev/null +++ b/backend_v1/app/jobs/video_store_short.go @@ -0,0 +1,135 @@ +package jobs + +import ( + "context" + "path/filepath" + "time" + + "quyun/v2/app/model" + "quyun/v2/database/fields" + "quyun/v2/pkg/utils" + "quyun/v2/providers/ali" + "quyun/v2/providers/app" + "quyun/v2/providers/job" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*VideoStoreShort)(nil) + +type VideoStoreShort struct { + MediaHash string `json:"media_hash"` + FilePath string `json:"file_path"` +} + +func (s VideoStoreShort) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (s VideoStoreShort) Kind() string { return "video_store_short" } +func (a VideoStoreShort) UniqueID() string { return a.Kind() } + +var _ Worker[VideoStoreShort] = (*VideoStoreShortWorker)(nil) + +// @provider(job) +type VideoStoreShortWorker struct { + WorkerDefaults[VideoStoreShort] + + oss *ali.OSSClient + job *job.Job + app *app.Config +} + +func (w *VideoStoreShortWorker) NextRetry(job *Job[VideoStoreShort]) time.Time { + return time.Now().Add(30 * time.Second) +} + +func (w *VideoStoreShortWorker) Work(ctx context.Context, job *Job[VideoStoreShort]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + media, err := model.MediasModel().GetByHash(ctx, job.Args.MediaHash) + if err != nil { + log.Errorf("Error getting media by ID: %v", err) + return JobCancel(err) + } + + duration, err := utils.GetMediaDuration(job.Args.FilePath) + if err != nil { + log.Errorf("Error getting media duration: %v", err) + return errors.Wrap(err, "failed to get media duration") + } + + // get file md5 + log.Infof("pending get file md5 %s", job.Args.FilePath) + fileMd5, err := utils.GetFileMd5(job.Args.FilePath) + if err != nil { + log.Errorf("Error getting file md5: %v", err) + return errors.Wrap(err, "failed to get file md5") + } + log.Infof("got file md5 %s %s", job.Args.FilePath, fileMd5) + + filePath := w.oss.GetSavePath(fileMd5 + filepath.Ext(job.Args.FilePath)) + + // get file size + log.Infof("pending get file size %s", job.Args.FilePath) + fileSize, err := utils.GetFileSize(job.Args.FilePath) + if err != nil { + log.Errorf("Error getting file size: %v", err) + return errors.Wrap(err, "failed to get file size") + } + log.Infof("got file size %s %d", job.Args.FilePath, fileSize) + + // save to db and relate to master + mediaModel := &model.Medias{ + Name: "[试听] " + media.Name, + MimeType: media.MimeType, + Size: fileSize, + Path: filePath, + Hash: fileMd5, + Metas: fields.ToJson(fields.MediaMetas{ + ParentHash: media.Hash, + Short: true, + Duration: duration, + }), + } + + // upload to oss + log.Infof("pending upload file to oss %s", job.Args.FilePath) + if err := w.oss.Upload(ctx, job.Args.FilePath, filePath, ali.WithInternal()); err != nil { + log.Errorf("Error uploading file to OSS: %v", err) + return err + } + + log.Infof("pending create media record %s", job.Args.FilePath) + if err := mediaModel.Create(ctx); err != nil { + log.Errorf("Error saving media record: %v data: %+v", err, mediaModel) + return err + } + log.Infof("Media record created with path: %s and hash: %s", filePath, fileMd5) + + log.Infof("pending remove local storage file %s", job.Args.FilePath) + if err := w.job.Add(&RemoveFile{FilePath: job.Args.FilePath}); err != nil { + log.Errorf("Error removing original file: %v", err) + } + + return w.NextJob(media.Hash) +} + +func (w *VideoStoreShortWorker) NextJob(hash string) error { + if err := w.job.Add(&VideoExtractHeadImage{MediaHash: hash}); err != nil { + log.Errorf("Error adding job: %v", err) + return err + } + + return nil +} diff --git a/backend_v1/app/services/medias.go b/backend_v1/app/services/medias.go new file mode 100644 index 0000000..c374187 --- /dev/null +++ b/backend_v1/app/services/medias.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/fields" + + "github.com/pkg/errors" + "go.ipao.vip/gen" +) + +// @provider +type medias struct{} + +func (m *medias) List( + ctx context.Context, + pagination *requests.Pagination, + conds ...gen.Condition, +) (*requests.Pager, error) { + pagination.Format() + + tbl, query := models.MediaQuery.QueryContext(ctx) + + items, cnt, err := query. + Where(conds...). + Order(tbl.ID.Desc()). + FindByPage(int(pagination.Offset()), int(pagination.Limit)) + if err != nil { + return nil, errors.Wrap(err, "failed to list media items") + } + + return &requests.Pager{ + Items: items, + Total: cnt, + Pagination: *pagination, + }, nil +} + +// GetByIds +func (m *medias) GetByIds(ctx context.Context, ids []int64) ([]*models.Media, error) { + if len(ids) == 0 { + return []*models.Media{}, nil + } + + tbl, query := models.MediaQuery.QueryContext(ctx) + + items, err := query. + Where(tbl.ID.In(ids...)). + Find() + if err != nil { + return nil, errors.Wrap(err, "failed to get media items by ids") + } + + return items, nil +} + +// GetByHash +func (m *medias) GetByHash(ctx context.Context, hash string) (*models.Media, error) { + tbl, query := models.MediaQuery.QueryContext(ctx) + item, err := query. + Where(tbl.Hash.Eq(hash)). + First() + if err != nil { + return nil, errors.Wrap(err, "failed to get media item by hash") + } + return item, nil +} + +// UpdateMetas +func (m *medias) UpdateMetas(ctx context.Context, id int64, metas fields.MediaMetas) error { + tbl, query := models.MediaQuery.QueryContext(ctx) + _, err := query. + Where(tbl.ID.Eq(id)). + Update(tbl.Metas, metas) + if err != nil { + return errors.Wrapf(err, "failed to update media metas for id: %d", id) + } + return nil +} + +// GetRelationMedias + +func (m *medias) GetRelations(ctx context.Context, hash string) ([]*models.Media, error) { + tbl, query := models.MediaQuery.QueryContext(ctx) + return query.Where(tbl.Metas.KeyEq("parent_hash", hash)).Find() +} diff --git a/backend_v1/app/services/orders.go b/backend_v1/app/services/orders.go new file mode 100644 index 0000000..65f30e8 --- /dev/null +++ b/backend_v1/app/services/orders.go @@ -0,0 +1,133 @@ +package services + +import ( + "context" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/fields" + + "github.com/pkg/errors" + "github.com/samber/lo" + "go.ipao.vip/gen" +) + +// @provider +type orders struct{} + +// List 订单列表(支持按订单号模糊查询、按用户过滤)。 +func (m *orders) List( + ctx context.Context, + pagination *requests.Pagination, + orderNumber *string, + userID *int64, +) (*requests.Pager, error) { + pagination.Format() + + tbl, query := models.OrderQuery.QueryContext(ctx) + + conds := make([]gen.Condition, 0, 2) + if orderNumber != nil && *orderNumber != "" { + conds = append(conds, tbl.OrderNo.Like("%"+*orderNumber+"%")) + } + if userID != nil { + conds = append(conds, tbl.UserID.Eq(*userID)) + } + + orders, cnt, err := query. + Where(conds...). + Order(tbl.ID.Desc()). + FindByPage(int(pagination.Offset()), int(pagination.Limit)) + if err != nil { + return nil, errors.Wrap(err, "failed to list orders") + } + + // 这里刻意使用“先查订单,再批量查关联”的方式,避免在分页时 JOIN 造成重复行/分页不稳定。 + postIDs := lo.Uniq(lo.Map(orders, func(o *models.Order, _ int) int64 { return o.PostID })) + userIDs := lo.Uniq(lo.Map(orders, func(o *models.Order, _ int) int64 { return o.UserID })) + + posts, err := models.PostQuery.WithContext(ctx).GetByIDs(postIDs...) + if err != nil { + return nil, errors.Wrap(err, "failed to get posts by ids") + } + users, err := models.UserQuery.WithContext(ctx).GetByIDs(userIDs...) + if err != nil { + return nil, errors.Wrap(err, "failed to get users by ids") + } + + postMap := lo.SliceToMap(posts, func(p *models.Post) (int64, *models.Post) { return p.ID, p }) + userMap := lo.SliceToMap(users, func(u *models.User) (int64, *models.User) { return u.ID, u }) + + // OrderListItem 用于订单列表展示,补充作品标题与用户名等冗余信息,避免前端二次查询。 + type orderListItem struct { + *models.Order + PostTitle string `json:"post_title"` + Username string `json:"username"` + } + + items := lo.Map(orders, func(o *models.Order, _ int) *orderListItem { + item := &orderListItem{Order: o} + if post, ok := postMap[o.PostID]; ok { + item.PostTitle = post.Title + } + if user, ok := userMap[o.UserID]; ok { + item.Username = user.Username + } + return item + }) + + return &requests.Pager{ + Items: items, + Total: cnt, + Pagination: *pagination, + }, nil +} + +// Refund 订单退款(余额支付走本地退款;微信支付走微信退款并标记为退款处理中)。 +func (m *orders) Refund(ctx context.Context, id int64) error { + // 余额支付:这里强调“状态一致性”,必须在一个事务中完成:余额退回 + 撤销购买权益 + 更新订单状态。 + return models.Q.Transaction(func(tx *models.Query) error { + order, err := tx.Order.WithContext(ctx).GetByID(id) + if err != nil { + return errors.Wrap(err, "failed to get order in tx") + } + + // 退回余额(使用原子自增,避免并发覆盖)。 + costBalance := order.Meta.Data().CostBalance + if costBalance > 0 { + if _, err := tx.User. + WithContext(ctx). + Where(tx.User.ID.Eq(order.UserID)). + Inc(tx.User.Balance, costBalance); err != nil { + return errors.Wrap(err, "failed to refund balance") + } + } + + // 撤销已购买的作品权限(删除 user_posts 记录)。 + if _, err := tx.UserPost. + WithContext(ctx). + Where( + tx.UserPost.UserID.Eq(order.UserID), + tx.UserPost.PostID.Eq(order.PostID), + ). + Delete(); err != nil { + return errors.Wrap(err, "failed to revoke user post") + } + + // 更新订单状态为“退款成功”。 + if _, err := tx.Order. + WithContext(ctx). + Where(tx.Order.ID.Eq(order.ID)). + Update(tx.Order.Status, fields.OrderStatusRefundSuccess); err != nil { + return errors.Wrap(err, "failed to update order status") + } + + return nil + }) + +} + +// GetByOrderNO +func (m *orders) GetByOrderNO(ctx context.Context, orderNo string) (*models.Order, error) { + return models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.OrderNo.Eq(orderNo)).First() +} diff --git a/backend_v1/app/services/posts.go b/backend_v1/app/services/posts.go new file mode 100644 index 0000000..7eb22e8 --- /dev/null +++ b/backend_v1/app/services/posts.go @@ -0,0 +1,150 @@ +package services + +import ( + "context" + "quyun/v2/app/requests" + "quyun/v2/database/models" + "time" + + "github.com/pkg/errors" + "github.com/samber/lo" + "go.ipao.vip/gen" +) + +// @provider +type posts struct{} + +// IncrViewCount +func (m *posts) IncrViewCount(ctx context.Context, postID int64) error { + tbl, query := models.PostQuery.QueryContext(ctx) + + _, err := query.Where(tbl.ID.Eq(postID)).Inc(tbl.Views, 1) + if err != nil { + return errors.Wrapf(err, "failed to increment view count for post %d", postID) + } + return nil +} + +// List +func (m *posts) List( + ctx context.Context, + pagination *requests.Pagination, + conds ...gen.Condition, +) (*requests.Pager, error) { + pagination.Format() + + tbl, query := models.PostQuery.QueryContext(ctx) + items, cnt, err := query.Where(conds...). + Order(tbl.ID.Desc()). + FindByPage(int(pagination.Offset()), int(pagination.Limit)) + if err != nil { + return nil, errors.Wrap(err, "list post failed") + } + + return &requests.Pager{ + Items: items, + Total: cnt, + Pagination: *pagination, + }, nil +} + +// SendTo +func (m *posts) SendTo(ctx context.Context, postID, userID int64) error { + model := &models.UserPost{ + UserID: userID, + PostID: postID, + Price: -1, + } + return model.Create(ctx) +} + +// PostBoughtStatistics 获取指定文件 ID 的购买次数 +func (m *posts) BoughtStatistics(ctx context.Context, postIds []int64) (map[int64]int64, error) { + tbl, query := models.UserPostQuery.QueryContext(ctx) + + var items []struct { + Count int64 + PostID int64 + } + err := query.Select( + tbl.UserID.Count().As("count"), + tbl.PostID, + ). + Where(tbl.PostID.In(postIds...)). + Group(tbl.PostID).Scan(&items) + if err != nil { + return nil, err + } + result := make(map[int64]int64) + for _, item := range items { + result[item.PostID] = item.Count + } + return result, nil +} + +// Bought 获取用户购买记录 +func (m *posts) Bought(ctx context.Context, userId int64, pagination *requests.Pagination) (*requests.Pager, error) { + pagination.Format() + tbl, query := models.UserPostQuery.QueryContext(ctx) + + items, cnt, err := query. + Where(tbl.UserID.Eq(userId)). + FindByPage(int(pagination.Offset()), int(pagination.Limit)) + if err != nil { + return nil, err + } + postIds := lo.Map(items, func(item *models.UserPost, _ int) int64 { return item.PostID }) + postInfoMap := lo.KeyBy(items, func(item *models.UserPost) int64 { return item.PostID }) + + postItemMap, err := m.GetPostsMapByIDs(ctx, postIds) + if err != nil { + return nil, err + } + + type retItem struct { + Title string `json:"title"` + Price int64 `json:"price"` + BoughtAt time.Time `json:"bought_at"` + } + + var retItems []retItem + for _, postID := range postIds { + post, ok := postItemMap[postID] + if !ok { + continue + } + + postInfo := postInfoMap[postID] + + retItems = append(retItems, retItem{ + Title: post.Title, + Price: postInfo.Price, + BoughtAt: postInfo.CreatedAt, + }) + } + + return &requests.Pager{ + Items: retItems, + Total: cnt, + Pagination: *pagination, + }, nil +} + +// GetPostsMapByIDs +func (m *posts) GetPostsMapByIDs(ctx context.Context, ids []int64) (map[int64]*models.Post, error) { + tbl, query := models.PostQuery.QueryContext(ctx) + posts, err := query.Where(tbl.ID.In(ids...)).Find() + if err != nil { + return nil, err + } + return lo.KeyBy(posts, func(item *models.Post) int64 { return item.ID }), nil +} + +// GetMediaByIds +func (m *posts) GetMediaByIds(ctx context.Context, ids []int64) ([]*models.Media, error) { + if len(ids) == 0 { + return nil, nil + } + tbl, query := models.MediaQuery.QueryContext(ctx) + return query.Where(tbl.ID.In(ids...)).Find() +} diff --git a/backend_v1/app/services/provider.gen.go b/backend_v1/app/services/provider.gen.go index b6c4169..a60cfee 100755 --- a/backend_v1/app/services/provider.gen.go +++ b/backend_v1/app/services/provider.gen.go @@ -6,16 +6,35 @@ import ( "go.ipao.vip/atom/contracts" "go.ipao.vip/atom/opt" "gorm.io/gorm" + "quyun/v2/providers/wepay" ) func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func() (*medias, error) { + obj := &medias{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func(wepayClient *wepay.Client) (*orders, error) { + obj := &orders{ + wepay: wepayClient, + } + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( db *gorm.DB, - test *test, + medias *medias, + orders *orders, ) (contracts.Initial, error) { obj := &services{ - db: db, - test: test, + db: db, + medias: medias, + orders: orders, } if err := obj.Prepare(); err != nil { return nil, err @@ -25,12 +44,5 @@ func Provide(opts ...opt.Option) error { }, atom.GroupInitial); err != nil { return err } - if err := container.Container.Provide(func() (*test, error) { - obj := &test{} - - return obj, nil - }); err != nil { - return err - } return nil } diff --git a/backend_v1/app/services/services.gen.go b/backend_v1/app/services/services.gen.go index e65f276..a3859df 100644 --- a/backend_v1/app/services/services.gen.go +++ b/backend_v1/app/services/services.gen.go @@ -8,21 +8,24 @@ var _db *gorm.DB // exported CamelCase Services var ( - Test *test + Medias *medias + Orders *orders ) // @provider(model) type services struct { db *gorm.DB // define Services - test *test + medias *medias + orders *orders } func (svc *services) Prepare() error { _db = svc.db // set exported Services here - Test = svc.test + Medias = svc.medias + Orders = svc.orders return nil } diff --git a/backend_v1/app/services/test.go b/backend_v1/app/services/test.go deleted file mode 100644 index 051d196..0000000 --- a/backend_v1/app/services/test.go +++ /dev/null @@ -1,10 +0,0 @@ -package services - -import "context" - -// @provider -type test struct{} - -func (t *test) Test(ctx context.Context) (string, error) { - return "Test", nil -} diff --git a/backend_v1/app/services/test_test.go b/backend_v1/app/services/test_test.go deleted file mode 100644 index 31a7e77..0000000 --- a/backend_v1/app/services/test_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package services - -import ( - "testing" - "time" - - "quyun/v2/app/commands/testx" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.uber.org/dig" -) - -type TestSuiteInjectParams struct { - dig.In - - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type TestSuite struct { - suite.Suite - - TestSuiteInjectParams -} - -func Test_Test(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p TestSuiteInjectParams) { - suite.Run(t, &TestSuite{TestSuiteInjectParams: p}) - }) -} - -func (t *TestSuite) Test_Test() { - Convey("test_work", t.T(), func() { - t.T().Log("start test at", time.Now()) - }) -} diff --git a/backend_v1/database/.transform.yaml b/backend_v1/database/.transform.yaml index 76fc5cc..9819e04 100644 --- a/backend_v1/database/.transform.yaml +++ b/backend_v1/database/.transform.yaml @@ -1,12 +1,31 @@ ignores: -- migrations -- river_client -- river_client_queue -- river_job -- river_leader -- river_migration -- river_queue + - migrations + - river_client + - river_client_queue + - river_job + - river_leader + - river_migration + - river_queue imports: -- go.ipao.vip/gen + - go.ipao.vip/gen + - quyun/v2/pkg/fields field_type: + posts: + status: fields.PostStatus + assets: types.JSONType[[]fields.MediaAsset] + tags: types.JSONType[[]string] + meta: types.JSONType[fields.PostMeta] + head_images: types.JSONType[[]int64] + + users: + status: fields.UserStatus + metas: types.JSONType[fields.UserMetas] + auth_token: types.JSONType[fields.UserAuthToken] + + orders: + status: fields.OrderStatus + meta: types.JSONType[fields.OrderMeta] + + medias: + metas: types.JSONType[fields.MediaMetas] field_relate: diff --git a/backend_v1/database/models/medias.gen.go b/backend_v1/database/models/medias.gen.go index a82f3d8..cd8f7e8 100644 --- a/backend_v1/database/models/medias.gen.go +++ b/backend_v1/database/models/medias.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/fields" + "go.ipao.vip/gen" "go.ipao.vip/gen/types" ) @@ -16,14 +18,14 @@ const TableNameMedia = "medias" // Media mapped from table type Media struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` - Name string `gorm:"column:name;type:character varying(255);not null" json:"name"` - MimeType string `gorm:"column:mime_type;type:character varying(128);not null" json:"mime_type"` - Size int64 `gorm:"column:size;type:bigint;not null" json:"size"` - Path string `gorm:"column:path;type:character varying(255);not null" json:"path"` - Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` - Hash string `gorm:"column:hash;type:character varying(64);not null" json:"hash"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` + Name string `gorm:"column:name;type:character varying(255);not null" json:"name"` + MimeType string `gorm:"column:mime_type;type:character varying(128);not null" json:"mime_type"` + Size int64 `gorm:"column:size;type:bigint;not null" json:"size"` + Path string `gorm:"column:path;type:character varying(255);not null" json:"path"` + Metas types.JSONType[fields.MediaMetas] `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` + Hash string `gorm:"column:hash;type:character varying(64);not null" json:"hash"` } // Quick operations without importing query package diff --git a/backend_v1/database/models/orders.gen.go b/backend_v1/database/models/orders.gen.go index 86cee2d..5bfa492 100644 --- a/backend_v1/database/models/orders.gen.go +++ b/backend_v1/database/models/orders.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/fields" + "go.ipao.vip/gen" "go.ipao.vip/gen/types" ) @@ -16,21 +18,21 @@ const TableNameOrder = "orders" // Order mapped from table type Order struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` - OrderNo string `gorm:"column:order_no;type:character varying(64);not null" json:"order_no"` - SubOrderNo string `gorm:"column:sub_order_no;type:character varying(64);not null" json:"sub_order_no"` - TransactionID string `gorm:"column:transaction_id;type:character varying(64);not null" json:"transaction_id"` - RefundTransactionID string `gorm:"column:refund_transaction_id;type:character varying(64);not null" json:"refund_transaction_id"` - Price int64 `gorm:"column:price;type:bigint;not null" json:"price"` - Discount int16 `gorm:"column:discount;type:smallint;not null;default:100" json:"discount"` - Currency string `gorm:"column:currency;type:character varying(10);not null;default:CNY" json:"currency"` - PaymentMethod string `gorm:"column:payment_method;type:character varying(50);not null;default:wechatpay" json:"payment_method"` - PostID int64 `gorm:"column:post_id;type:bigint;not null" json:"post_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Status int16 `gorm:"column:status;type:smallint;not null" json:"status"` - Meta types.JSON `gorm:"column:meta;type:jsonb;not null;default:{}" json:"meta"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` + OrderNo string `gorm:"column:order_no;type:character varying(64);not null" json:"order_no"` + SubOrderNo string `gorm:"column:sub_order_no;type:character varying(64);not null" json:"sub_order_no"` + TransactionID string `gorm:"column:transaction_id;type:character varying(64);not null" json:"transaction_id"` + RefundTransactionID string `gorm:"column:refund_transaction_id;type:character varying(64);not null" json:"refund_transaction_id"` + Price int64 `gorm:"column:price;type:bigint;not null" json:"price"` + Discount int16 `gorm:"column:discount;type:smallint;not null;default:100" json:"discount"` + Currency string `gorm:"column:currency;type:character varying(10);not null;default:CNY" json:"currency"` + PaymentMethod string `gorm:"column:payment_method;type:character varying(50);not null;default:wechatpay" json:"payment_method"` + PostID int64 `gorm:"column:post_id;type:bigint;not null" json:"post_id"` + UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` + Status fields.OrderStatus `gorm:"column:status;type:smallint;not null" json:"status"` + Meta types.JSONType[fields.OrderMeta] `gorm:"column:meta;type:jsonb;not null;default:{}" json:"meta"` } // Quick operations without importing query package diff --git a/backend_v1/database/models/orders.query.gen.go b/backend_v1/database/models/orders.query.gen.go index 6a6a9bf..9ff55da 100644 --- a/backend_v1/database/models/orders.query.gen.go +++ b/backend_v1/database/models/orders.query.gen.go @@ -38,7 +38,7 @@ func newOrder(db *gorm.DB, opts ...gen.DOOption) orderQuery { _orderQuery.PaymentMethod = field.NewString(tableName, "payment_method") _orderQuery.PostID = field.NewInt64(tableName, "post_id") _orderQuery.UserID = field.NewInt64(tableName, "user_id") - _orderQuery.Status = field.NewInt16(tableName, "status") + _orderQuery.Status = field.NewField(tableName, "status") _orderQuery.Meta = field.NewJSONB(tableName, "meta") _orderQuery.fillFieldMap() @@ -63,7 +63,7 @@ type orderQuery struct { PaymentMethod field.String PostID field.Int64 UserID field.Int64 - Status field.Int16 + Status field.Field Meta field.JSONB fieldMap map[string]field.Expr @@ -94,7 +94,7 @@ func (o *orderQuery) updateTableName(table string) *orderQuery { o.PaymentMethod = field.NewString(table, "payment_method") o.PostID = field.NewInt64(table, "post_id") o.UserID = field.NewInt64(table, "user_id") - o.Status = field.NewInt16(table, "status") + o.Status = field.NewField(table, "status") o.Meta = field.NewJSONB(table, "meta") o.fillFieldMap() diff --git a/backend_v1/database/models/post.go b/backend_v1/database/models/post.go new file mode 100644 index 0000000..c4f204d --- /dev/null +++ b/backend_v1/database/models/post.go @@ -0,0 +1,5 @@ +package models + +func (m *Post) PayPrice() int64 { + return m.Price * int64(m.Discount) / 100 +} diff --git a/backend_v1/database/models/posts.gen.go b/backend_v1/database/models/posts.gen.go index 1925e05..66eb32c 100644 --- a/backend_v1/database/models/posts.gen.go +++ b/backend_v1/database/models/posts.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/fields" + "go.ipao.vip/gen" "go.ipao.vip/gen/types" "gorm.io/gorm" @@ -17,21 +19,21 @@ const TableNamePost = "posts" // Post mapped from table type Post struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone" json:"deleted_at"` - Status int16 `gorm:"column:status;type:smallint;not null" json:"status"` - Title string `gorm:"column:title;type:character varying(128);not null" json:"title"` - HeadImages types.JSON `gorm:"column:head_images;type:jsonb;not null;default:[]" json:"head_images"` - Description string `gorm:"column:description;type:character varying(256);not null" json:"description"` - Content string `gorm:"column:content;type:text;not null" json:"content"` - Price int64 `gorm:"column:price;type:bigint;not null" json:"price"` - Discount int16 `gorm:"column:discount;type:smallint;not null;default:100" json:"discount"` - Views int64 `gorm:"column:views;type:bigint;not null" json:"views"` - Likes int64 `gorm:"column:likes;type:bigint;not null" json:"likes"` - Tags types.JSON `gorm:"column:tags;type:jsonb;default:{}" json:"tags"` - Assets types.JSON `gorm:"column:assets;type:jsonb;default:{}" json:"assets"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone" json:"deleted_at"` + Status fields.PostStatus `gorm:"column:status;type:smallint;not null" json:"status"` + Title string `gorm:"column:title;type:character varying(128);not null" json:"title"` + HeadImages types.JSONType[[]int64] `gorm:"column:head_images;type:jsonb;not null;default:[]" json:"head_images"` + Description string `gorm:"column:description;type:character varying(256);not null" json:"description"` + Content string `gorm:"column:content;type:text;not null" json:"content"` + Price int64 `gorm:"column:price;type:bigint;not null" json:"price"` + Discount int16 `gorm:"column:discount;type:smallint;not null;default:100" json:"discount"` + Views int64 `gorm:"column:views;type:bigint;not null" json:"views"` + Likes int64 `gorm:"column:likes;type:bigint;not null" json:"likes"` + Tags types.JSONType[[]string] `gorm:"column:tags;type:jsonb;default:{}" json:"tags"` + Assets types.JSONType[[]fields.MediaAsset] `gorm:"column:assets;type:jsonb;default:{}" json:"assets"` } // Quick operations without importing query package diff --git a/backend_v1/database/models/posts.query.gen.go b/backend_v1/database/models/posts.query.gen.go index efdbb00..fa073f7 100644 --- a/backend_v1/database/models/posts.query.gen.go +++ b/backend_v1/database/models/posts.query.gen.go @@ -29,7 +29,7 @@ func newPost(db *gorm.DB, opts ...gen.DOOption) postQuery { _postQuery.CreatedAt = field.NewTime(tableName, "created_at") _postQuery.UpdatedAt = field.NewTime(tableName, "updated_at") _postQuery.DeletedAt = field.NewField(tableName, "deleted_at") - _postQuery.Status = field.NewInt16(tableName, "status") + _postQuery.Status = field.NewField(tableName, "status") _postQuery.Title = field.NewString(tableName, "title") _postQuery.HeadImages = field.NewJSONB(tableName, "head_images") _postQuery.Description = field.NewString(tableName, "description") @@ -54,7 +54,7 @@ type postQuery struct { CreatedAt field.Time UpdatedAt field.Time DeletedAt field.Field - Status field.Int16 + Status field.Field Title field.String HeadImages field.JSONB Description field.String @@ -85,7 +85,7 @@ func (p *postQuery) updateTableName(table string) *postQuery { p.CreatedAt = field.NewTime(table, "created_at") p.UpdatedAt = field.NewTime(table, "updated_at") p.DeletedAt = field.NewField(table, "deleted_at") - p.Status = field.NewInt16(table, "status") + p.Status = field.NewField(table, "status") p.Title = field.NewString(table, "title") p.HeadImages = field.NewJSONB(table, "head_images") p.Description = field.NewString(table, "description") diff --git a/backend_v1/database/models/users.gen.go b/backend_v1/database/models/users.gen.go index 9784f47..c1d1f8b 100644 --- a/backend_v1/database/models/users.gen.go +++ b/backend_v1/database/models/users.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/fields" + "go.ipao.vip/gen" "go.ipao.vip/gen/types" "gorm.io/gorm" @@ -17,17 +19,17 @@ const TableNameUser = "users" // User mapped from table type User struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone" json:"deleted_at"` - Status int16 `gorm:"column:status;type:smallint;not null" json:"status"` - OpenID string `gorm:"column:open_id;type:character varying(128);not null" json:"open_id"` - Username string `gorm:"column:username;type:character varying(128);not null" json:"username"` - Avatar string `gorm:"column:avatar;type:text" json:"avatar"` - Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` - AuthToken types.JSON `gorm:"column:auth_token;type:jsonb;not null;default:{}" json:"auth_token"` - Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp without time zone;not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp without time zone;not null;default:now()" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp without time zone" json:"deleted_at"` + Status fields.UserStatus `gorm:"column:status;type:smallint;not null" json:"status"` + OpenID string `gorm:"column:open_id;type:character varying(128);not null" json:"open_id"` + Username string `gorm:"column:username;type:character varying(128);not null" json:"username"` + Avatar string `gorm:"column:avatar;type:text" json:"avatar"` + Metas types.JSONType[fields.UserMetas] `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` + AuthToken types.JSONType[fields.UserAuthToken] `gorm:"column:auth_token;type:jsonb;not null;default:{}" json:"auth_token"` + Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"` } // Quick operations without importing query package diff --git a/backend_v1/database/models/users.query.gen.go b/backend_v1/database/models/users.query.gen.go index 0e3ec67..3dc20af 100644 --- a/backend_v1/database/models/users.query.gen.go +++ b/backend_v1/database/models/users.query.gen.go @@ -29,7 +29,7 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { _userQuery.CreatedAt = field.NewTime(tableName, "created_at") _userQuery.UpdatedAt = field.NewTime(tableName, "updated_at") _userQuery.DeletedAt = field.NewField(tableName, "deleted_at") - _userQuery.Status = field.NewInt16(tableName, "status") + _userQuery.Status = field.NewField(tableName, "status") _userQuery.OpenID = field.NewString(tableName, "open_id") _userQuery.Username = field.NewString(tableName, "username") _userQuery.Avatar = field.NewString(tableName, "avatar") @@ -50,7 +50,7 @@ type userQuery struct { CreatedAt field.Time UpdatedAt field.Time DeletedAt field.Field - Status field.Int16 + Status field.Field OpenID field.String Username field.String Avatar field.String @@ -77,7 +77,7 @@ func (u *userQuery) updateTableName(table string) *userQuery { u.CreatedAt = field.NewTime(table, "created_at") u.UpdatedAt = field.NewTime(table, "updated_at") u.DeletedAt = field.NewField(table, "deleted_at") - u.Status = field.NewInt16(table, "status") + u.Status = field.NewField(table, "status") u.OpenID = field.NewString(table, "open_id") u.Username = field.NewString(table, "username") u.Avatar = field.NewString(table, "avatar") diff --git a/backend_v1/go.mod b/backend_v1/go.mod index 95cd9df..ed00ec2 100644 --- a/backend_v1/go.mod +++ b/backend_v1/go.mod @@ -7,11 +7,17 @@ require ( github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.2 github.com/ThreeDotsLabs/watermill-redisstream v1.4.5 github.com/ThreeDotsLabs/watermill-sql/v3 v3.1.0 + github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 + github.com/go-pay/errgroup v0.0.3 + github.com/go-pay/gopay v1.5.115 + github.com/go-pay/util v0.0.4 github.com/gofiber/fiber/v3 v3.0.0-rc.3 github.com/gofiber/utils/v2 v2.0.0-rc.4 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 + github.com/imroc/req/v3 v3.56.0 github.com/jackc/pgx/v5 v5.7.6 + github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.26.0 github.com/redis/go-redis/v9 v9.17.2 @@ -29,12 +35,16 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/files/v2 v2.0.2 go.ipao.vip/atom v1.2.1 + go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44 go.uber.org/dig v1.19.0 + golang.org/x/net v0.48.0 golang.org/x/sync v0.19.0 google.golang.org/grpc v1.75.1 google.golang.org/protobuf v1.36.11 + gopkg.in/retry.v1 v1.0.3 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 + gorm.io/plugin/dbresolver v1.6.2 ) require ( @@ -62,14 +72,20 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-pay/crypto v0.0.1 // indirect + github.com/go-pay/smap v0.0.2 // indirect + github.com/go-pay/xlog v0.0.3 // indirect + github.com/go-pay/xtime v0.0.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofiber/schema v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/icholy/digest v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -93,7 +109,10 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.56.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/refraction-networking/utls v1.8.1 // indirect github.com/riverqueue/river/riverdriver v0.28.0 // indirect github.com/riverqueue/river/rivershared v0.28.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -121,11 +140,12 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/hints v1.1.0 // indirect ) diff --git a/backend_v1/go.sum b/backend_v1/go.sum index d354a7b..8b37c5f 100644 --- a/backend_v1/go.sum +++ b/backend_v1/go.sum @@ -14,6 +14,8 @@ github.com/ThreeDotsLabs/watermill-redisstream v1.4.5 h1:SCETqsAYo/CRBb7H3+zWCcS github.com/ThreeDotsLabs/watermill-redisstream v1.4.5/go.mod h1:Da3wqG1OcvHPODjuJcxSCY1O7D4loIZQpVbZ5u94xRo= github.com/ThreeDotsLabs/watermill-sql/v3 v3.1.0 h1:g4uE5Nm3Z6LVB3m+uMgHlN4ne4bDpwf3RJmXYRgMv94= github.com/ThreeDotsLabs/watermill-sql/v3 v3.1.0/go.mod h1:G8/otZYWLTCeYL2Ww3ujQ7gQ/3+jw5Bj0UtyKn7bBjA= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -41,6 +43,7 @@ github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -79,6 +82,20 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-pay/crypto v0.0.1 h1:B6InT8CLfSLc6nGRVx9VMJRBBazFMjr293+jl0lLXUY= +github.com/go-pay/crypto v0.0.1/go.mod h1:41oEIvHMKbNcYlWUlRWtsnC6+ASgh7u29z0gJXe5bes= +github.com/go-pay/errgroup v0.0.3 h1:DB4s8e8oWYDyETKQ1y1riMJ7y29zE1uIsMCSjEOFSbU= +github.com/go-pay/errgroup v0.0.3/go.mod h1:0+4b8mvFMS71MIzsaC+gVvB4x37I93lRb2dqrwuU8x8= +github.com/go-pay/gopay v1.5.115 h1:8WjWftPChKCiVt5Qz2xLqXeUdidsR+y9/R2S/7Q9szc= +github.com/go-pay/gopay v1.5.115/go.mod h1:p48xvWeepPolZuakAjCeucWynWwW7msoXsqahcoJpKE= +github.com/go-pay/smap v0.0.2 h1:kKflYor5T5FgZltPFBMTFfjJvqYMHr5VnIFSEyhVTcA= +github.com/go-pay/smap v0.0.2/go.mod h1:HW9oAo0okuyDYsbpbj5fJFxnNj/BZorRGFw26SxrNWw= +github.com/go-pay/util v0.0.4 h1:TuwSU9o3Qd7m9v1PbzFuIA/8uO9FJnA6P7neG/NwPyk= +github.com/go-pay/util v0.0.4/go.mod h1:Tsdhs8Ib9J9b4+NKNO1PHh5hWHhlg98PthsX0ckq6PM= +github.com/go-pay/xlog v0.0.3 h1:avyMhCL/JgBHreoGx/am/kHxfs1udDOAeVqbmzP/Yes= +github.com/go-pay/xlog v0.0.3/go.mod h1:mH47xbobrdsSHWsmFtSF5agWbMHFP+tK0ZbVCk5OAEw= +github.com/go-pay/xtime v0.0.2 h1:7YR4/iuELsEHpJ6LUO0SVK80hQxDO9MLCfuVYIiTCRM= +github.com/go-pay/xtime v0.0.2/go.mod h1:W1yRbJaSt4CSBcdAtLBQ8xajiN/Pl5hquGczUcUE9xE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -97,9 +114,13 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -115,6 +136,10 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9 github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= +github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= +github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo= +github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -153,14 +178,20 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las= +github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -171,6 +202,9 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -190,10 +224,16 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= +github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= +github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/riverqueue/river v0.28.0 h1:j+1vqwRkFzI0kWTbU0p5mH+hX5x8ZJiyVH4p6T1OqLU= @@ -214,6 +254,8 @@ github.com/rogeecn/fabfile v1.7.0 h1:qtwkqaBsJjWrggbvznbd0HGyJ0ebBTOBE893JvD5Tng github.com/rogeecn/fabfile v1.7.0/go.mod h1:EPwX7TtVcIWSLJkJAqxSzYjM/aV1Q0wymcaXqnMgzas= github.com/rogeecn/swag v1.0.1 h1:s1yxLgopqO1m8sqGjVmt6ocMBRubMPIh2JtIPG4xjQE= github.com/rogeecn/swag v1.0.1/go.mod h1:flG2NXERPxlRl2VdpU2VXTO8iBnQiERyowOXSkZVMOc= +github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= +github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -285,6 +327,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.ipao.vip/atom v1.2.1 h1:7VlDLSkGNVEZLVM/JVcXXdMTO0+sFsxe1vfIM4Xz8uc= go.ipao.vip/atom v1.2.1/go.mod h1:woAv+rZf0xd+7mEtKWv4PyazQARFLnrV/qA4qlAK008= +go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44 h1:i7zFEsfUYRJQo0mXUWI/RoEkgEdTNmLt0Io2rwhqY9E= +go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44/go.mod h1:ip5X9ioxR9hvM/mrsA77KWXFsrMm5oki5rfY5MSkssM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -301,6 +345,8 @@ go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -352,6 +398,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -374,14 +422,29 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= +gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.1.6/go.mod h1:W8LmC/6UvVbHKah0+QOC7Ja66EaZXHwUTjgXY8YNWX8= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw= +gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/backend_v1/pkg/fields/medias.go b/backend_v1/pkg/fields/medias.go new file mode 100644 index 0000000..d057228 --- /dev/null +++ b/backend_v1/pkg/fields/medias.go @@ -0,0 +1,7 @@ +package fields + +type MediaMetas struct { + ParentHash string `json:"parent_hash,omitempty"` + Short bool `json:"short,omitempty"` + Duration int64 `json:"duration,omitempty"` +} diff --git a/backend_v1/pkg/fields/orders.gen.go b/backend_v1/pkg/fields/orders.gen.go new file mode 100644 index 0000000..ff66c77 --- /dev/null +++ b/backend_v1/pkg/fields/orders.gen.go @@ -0,0 +1,271 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: - +// Revision: - +// Build Date: - +// Built By: - + +package fields + +import ( + "database/sql/driver" + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // OrderStatusPending is a OrderStatus of type Pending. + OrderStatusPending OrderStatus = iota + // OrderStatusPaid is a OrderStatus of type Paid. + OrderStatusPaid + // OrderStatusRefundSuccess is a OrderStatus of type Refund_success. + OrderStatusRefundSuccess + // OrderStatusRefundClosed is a OrderStatus of type Refund_closed. + OrderStatusRefundClosed + // OrderStatusRefundProcessing is a OrderStatus of type Refund_processing. + OrderStatusRefundProcessing + // OrderStatusRefundAbnormal is a OrderStatus of type Refund_abnormal. + OrderStatusRefundAbnormal + // OrderStatusCancelled is a OrderStatus of type Cancelled. + OrderStatusCancelled + // OrderStatusCompleted is a OrderStatus of type Completed. + OrderStatusCompleted +) + +var ErrInvalidOrderStatus = fmt.Errorf("not a valid OrderStatus, try [%s]", strings.Join(_OrderStatusNames, ", ")) + +const _OrderStatusName = "pendingpaidrefund_successrefund_closedrefund_processingrefund_abnormalcancelledcompleted" + +var _OrderStatusNames = []string{ + _OrderStatusName[0:7], + _OrderStatusName[7:11], + _OrderStatusName[11:25], + _OrderStatusName[25:38], + _OrderStatusName[38:55], + _OrderStatusName[55:70], + _OrderStatusName[70:79], + _OrderStatusName[79:88], +} + +// OrderStatusNames returns a list of possible string values of OrderStatus. +func OrderStatusNames() []string { + tmp := make([]string, len(_OrderStatusNames)) + copy(tmp, _OrderStatusNames) + return tmp +} + +// OrderStatusValues returns a list of the values for OrderStatus +func OrderStatusValues() []OrderStatus { + return []OrderStatus{ + OrderStatusPending, + OrderStatusPaid, + OrderStatusRefundSuccess, + OrderStatusRefundClosed, + OrderStatusRefundProcessing, + OrderStatusRefundAbnormal, + OrderStatusCancelled, + OrderStatusCompleted, + } +} + +var _OrderStatusMap = map[OrderStatus]string{ + OrderStatusPending: _OrderStatusName[0:7], + OrderStatusPaid: _OrderStatusName[7:11], + OrderStatusRefundSuccess: _OrderStatusName[11:25], + OrderStatusRefundClosed: _OrderStatusName[25:38], + OrderStatusRefundProcessing: _OrderStatusName[38:55], + OrderStatusRefundAbnormal: _OrderStatusName[55:70], + OrderStatusCancelled: _OrderStatusName[70:79], + OrderStatusCompleted: _OrderStatusName[79:88], +} + +// String implements the Stringer interface. +func (x OrderStatus) String() string { + if str, ok := _OrderStatusMap[x]; ok { + return str + } + return fmt.Sprintf("OrderStatus(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x OrderStatus) IsValid() bool { + _, ok := _OrderStatusMap[x] + return ok +} + +var _OrderStatusValue = map[string]OrderStatus{ + _OrderStatusName[0:7]: OrderStatusPending, + _OrderStatusName[7:11]: OrderStatusPaid, + _OrderStatusName[11:25]: OrderStatusRefundSuccess, + _OrderStatusName[25:38]: OrderStatusRefundClosed, + _OrderStatusName[38:55]: OrderStatusRefundProcessing, + _OrderStatusName[55:70]: OrderStatusRefundAbnormal, + _OrderStatusName[70:79]: OrderStatusCancelled, + _OrderStatusName[79:88]: OrderStatusCompleted, +} + +// ParseOrderStatus attempts to convert a string to a OrderStatus. +func ParseOrderStatus(name string) (OrderStatus, error) { + if x, ok := _OrderStatusValue[name]; ok { + return x, nil + } + return OrderStatus(0), fmt.Errorf("%s is %w", name, ErrInvalidOrderStatus) +} + +var errOrderStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *OrderStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = OrderStatus(0) + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case int64: + *x = OrderStatus(v) + case string: + *x, err = ParseOrderStatus(v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(v); verr == nil { + *x, err = OrderStatus(val), nil + } + } + case []byte: + *x, err = ParseOrderStatus(string(v)) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(string(v)); verr == nil { + *x, err = OrderStatus(val), nil + } + } + case OrderStatus: + *x = v + case int: + *x = OrderStatus(v) + case *OrderStatus: + if v == nil { + return errOrderStatusNilPtr + } + *x = *v + case uint: + *x = OrderStatus(v) + case uint64: + *x = OrderStatus(v) + case *int: + if v == nil { + return errOrderStatusNilPtr + } + *x = OrderStatus(*v) + case *int64: + if v == nil { + return errOrderStatusNilPtr + } + *x = OrderStatus(*v) + case float64: // json marshals everything as a float64 if it's a number + *x = OrderStatus(v) + case *float64: // json marshals everything as a float64 if it's a number + if v == nil { + return errOrderStatusNilPtr + } + *x = OrderStatus(*v) + case *uint: + if v == nil { + return errOrderStatusNilPtr + } + *x = OrderStatus(*v) + case *uint64: + if v == nil { + return errOrderStatusNilPtr + } + *x = OrderStatus(*v) + case *string: + if v == nil { + return errOrderStatusNilPtr + } + *x, err = ParseOrderStatus(*v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(*v); verr == nil { + *x, err = OrderStatus(val), nil + } + } + } + + return +} + +// Value implements the driver Valuer interface. +func (x OrderStatus) Value() (driver.Value, error) { + return int64(x), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *OrderStatus) Set(val string) error { + v, err := ParseOrderStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *OrderStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *OrderStatus) Type() string { + return "OrderStatus" +} + +type NullOrderStatus struct { + OrderStatus OrderStatus + Valid bool +} + +func NewNullOrderStatus(val interface{}) (x NullOrderStatus) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Scan implements the Scanner interface. +func (x *NullOrderStatus) Scan(value interface{}) (err error) { + if value == nil { + x.OrderStatus, x.Valid = OrderStatus(0), false + return + } + + err = x.OrderStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return int64(x.OrderStatus), nil +} + +type NullOrderStatusStr struct { + NullOrderStatus +} + +func NewNullOrderStatusStr(val interface{}) (x NullOrderStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.OrderStatus.String(), nil +} diff --git a/backend_v1/pkg/fields/orders.go b/backend_v1/pkg/fields/orders.go new file mode 100644 index 0000000..d154966 --- /dev/null +++ b/backend_v1/pkg/fields/orders.go @@ -0,0 +1,16 @@ +package fields + +import ( + "github.com/go-pay/gopay/wechat/v3" +) + +// swagger:enum OrderStatus +// ENUM( pending, paid, refund_success, refund_closed, refund_processing, refund_abnormal, cancelled, completed) +type OrderStatus int16 + +type OrderMeta struct { + PayNotify *wechat.V3DecryptPayResult `json:"pay_notify"` + RefundResp *wechat.RefundOrderResponse `json:"refund_resp"` + RefundNotify *wechat.V3DecryptRefundResult `json:"refund_notify"` + CostBalance int64 `json:"cost_balance"` // 余额支付的金额 +} diff --git a/backend_v1/pkg/fields/posts.gen.go b/backend_v1/pkg/fields/posts.gen.go new file mode 100644 index 0000000..447d6f2 --- /dev/null +++ b/backend_v1/pkg/fields/posts.gen.go @@ -0,0 +1,467 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: - +// Revision: - +// Build Date: - +// Built By: - + +package fields + +import ( + "database/sql/driver" + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // PostStatusDraft is a PostStatus of type Draft. + PostStatusDraft PostStatus = iota + // PostStatusPublished is a PostStatus of type Published. + PostStatusPublished +) + +var ErrInvalidPostStatus = fmt.Errorf("not a valid PostStatus, try [%s]", strings.Join(_PostStatusNames, ", ")) + +const _PostStatusName = "draftpublished" + +var _PostStatusNames = []string{ + _PostStatusName[0:5], + _PostStatusName[5:14], +} + +// PostStatusNames returns a list of possible string values of PostStatus. +func PostStatusNames() []string { + tmp := make([]string, len(_PostStatusNames)) + copy(tmp, _PostStatusNames) + return tmp +} + +// PostStatusValues returns a list of the values for PostStatus +func PostStatusValues() []PostStatus { + return []PostStatus{ + PostStatusDraft, + PostStatusPublished, + } +} + +var _PostStatusMap = map[PostStatus]string{ + PostStatusDraft: _PostStatusName[0:5], + PostStatusPublished: _PostStatusName[5:14], +} + +// String implements the Stringer interface. +func (x PostStatus) String() string { + if str, ok := _PostStatusMap[x]; ok { + return str + } + return fmt.Sprintf("PostStatus(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x PostStatus) IsValid() bool { + _, ok := _PostStatusMap[x] + return ok +} + +var _PostStatusValue = map[string]PostStatus{ + _PostStatusName[0:5]: PostStatusDraft, + _PostStatusName[5:14]: PostStatusPublished, +} + +// ParsePostStatus attempts to convert a string to a PostStatus. +func ParsePostStatus(name string) (PostStatus, error) { + if x, ok := _PostStatusValue[name]; ok { + return x, nil + } + return PostStatus(0), fmt.Errorf("%s is %w", name, ErrInvalidPostStatus) +} + +var errPostStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *PostStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = PostStatus(0) + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case int64: + *x = PostStatus(v) + case string: + *x, err = ParsePostStatus(v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(v); verr == nil { + *x, err = PostStatus(val), nil + } + } + case []byte: + *x, err = ParsePostStatus(string(v)) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(string(v)); verr == nil { + *x, err = PostStatus(val), nil + } + } + case PostStatus: + *x = v + case int: + *x = PostStatus(v) + case *PostStatus: + if v == nil { + return errPostStatusNilPtr + } + *x = *v + case uint: + *x = PostStatus(v) + case uint64: + *x = PostStatus(v) + case *int: + if v == nil { + return errPostStatusNilPtr + } + *x = PostStatus(*v) + case *int64: + if v == nil { + return errPostStatusNilPtr + } + *x = PostStatus(*v) + case float64: // json marshals everything as a float64 if it's a number + *x = PostStatus(v) + case *float64: // json marshals everything as a float64 if it's a number + if v == nil { + return errPostStatusNilPtr + } + *x = PostStatus(*v) + case *uint: + if v == nil { + return errPostStatusNilPtr + } + *x = PostStatus(*v) + case *uint64: + if v == nil { + return errPostStatusNilPtr + } + *x = PostStatus(*v) + case *string: + if v == nil { + return errPostStatusNilPtr + } + *x, err = ParsePostStatus(*v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(*v); verr == nil { + *x, err = PostStatus(val), nil + } + } + } + + return +} + +// Value implements the driver Valuer interface. +func (x PostStatus) Value() (driver.Value, error) { + return int64(x), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *PostStatus) Set(val string) error { + v, err := ParsePostStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *PostStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *PostStatus) Type() string { + return "PostStatus" +} + +type NullPostStatus struct { + PostStatus PostStatus + Valid bool +} + +func NewNullPostStatus(val interface{}) (x NullPostStatus) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Scan implements the Scanner interface. +func (x *NullPostStatus) Scan(value interface{}) (err error) { + if value == nil { + x.PostStatus, x.Valid = PostStatus(0), false + return + } + + err = x.PostStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullPostStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return int64(x.PostStatus), nil +} + +type NullPostStatusStr struct { + NullPostStatus +} + +func NewNullPostStatusStr(val interface{}) (x NullPostStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullPostStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.PostStatus.String(), nil +} + +const ( + // PostTypeArticle is a PostType of type Article. + PostTypeArticle PostType = iota + // PostTypePicture is a PostType of type Picture. + PostTypePicture + // PostTypeVideo is a PostType of type Video. + PostTypeVideo + // PostTypeAudio is a PostType of type Audio. + PostTypeAudio +) + +var ErrInvalidPostType = fmt.Errorf("not a valid PostType, try [%s]", strings.Join(_PostTypeNames, ", ")) + +const _PostTypeName = "ArticlePictureVideoAudio" + +var _PostTypeNames = []string{ + _PostTypeName[0:7], + _PostTypeName[7:14], + _PostTypeName[14:19], + _PostTypeName[19:24], +} + +// PostTypeNames returns a list of possible string values of PostType. +func PostTypeNames() []string { + tmp := make([]string, len(_PostTypeNames)) + copy(tmp, _PostTypeNames) + return tmp +} + +// PostTypeValues returns a list of the values for PostType +func PostTypeValues() []PostType { + return []PostType{ + PostTypeArticle, + PostTypePicture, + PostTypeVideo, + PostTypeAudio, + } +} + +var _PostTypeMap = map[PostType]string{ + PostTypeArticle: _PostTypeName[0:7], + PostTypePicture: _PostTypeName[7:14], + PostTypeVideo: _PostTypeName[14:19], + PostTypeAudio: _PostTypeName[19:24], +} + +// String implements the Stringer interface. +func (x PostType) String() string { + if str, ok := _PostTypeMap[x]; ok { + return str + } + return fmt.Sprintf("PostType(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x PostType) IsValid() bool { + _, ok := _PostTypeMap[x] + return ok +} + +var _PostTypeValue = map[string]PostType{ + _PostTypeName[0:7]: PostTypeArticle, + _PostTypeName[7:14]: PostTypePicture, + _PostTypeName[14:19]: PostTypeVideo, + _PostTypeName[19:24]: PostTypeAudio, +} + +// ParsePostType attempts to convert a string to a PostType. +func ParsePostType(name string) (PostType, error) { + if x, ok := _PostTypeValue[name]; ok { + return x, nil + } + return PostType(0), fmt.Errorf("%s is %w", name, ErrInvalidPostType) +} + +var errPostTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *PostType) Scan(value interface{}) (err error) { + if value == nil { + *x = PostType(0) + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case int64: + *x = PostType(v) + case string: + *x, err = ParsePostType(v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(v); verr == nil { + *x, err = PostType(val), nil + } + } + case []byte: + *x, err = ParsePostType(string(v)) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(string(v)); verr == nil { + *x, err = PostType(val), nil + } + } + case PostType: + *x = v + case int: + *x = PostType(v) + case *PostType: + if v == nil { + return errPostTypeNilPtr + } + *x = *v + case uint: + *x = PostType(v) + case uint64: + *x = PostType(v) + case *int: + if v == nil { + return errPostTypeNilPtr + } + *x = PostType(*v) + case *int64: + if v == nil { + return errPostTypeNilPtr + } + *x = PostType(*v) + case float64: // json marshals everything as a float64 if it's a number + *x = PostType(v) + case *float64: // json marshals everything as a float64 if it's a number + if v == nil { + return errPostTypeNilPtr + } + *x = PostType(*v) + case *uint: + if v == nil { + return errPostTypeNilPtr + } + *x = PostType(*v) + case *uint64: + if v == nil { + return errPostTypeNilPtr + } + *x = PostType(*v) + case *string: + if v == nil { + return errPostTypeNilPtr + } + *x, err = ParsePostType(*v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(*v); verr == nil { + *x, err = PostType(val), nil + } + } + } + + return +} + +// Value implements the driver Valuer interface. +func (x PostType) Value() (driver.Value, error) { + return int64(x), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *PostType) Set(val string) error { + v, err := ParsePostType(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *PostType) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *PostType) Type() string { + return "PostType" +} + +type NullPostType struct { + PostType PostType + Valid bool +} + +func NewNullPostType(val interface{}) (x NullPostType) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Scan implements the Scanner interface. +func (x *NullPostType) Scan(value interface{}) (err error) { + if value == nil { + x.PostType, x.Valid = PostType(0), false + return + } + + err = x.PostType.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullPostType) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return int64(x.PostType), nil +} + +type NullPostTypeStr struct { + NullPostType +} + +func NewNullPostTypeStr(val interface{}) (x NullPostTypeStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullPostTypeStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.PostType.String(), nil +} diff --git a/backend_v1/pkg/fields/posts.go b/backend_v1/pkg/fields/posts.go new file mode 100644 index 0000000..74f4f31 --- /dev/null +++ b/backend_v1/pkg/fields/posts.go @@ -0,0 +1,16 @@ +package fields + +type MediaAsset struct { + Type string `json:"type"` + Media int64 `json:"media"` + Metas *MediaMetas `json:"metas,omitempty"` + Mark *string `json:"mark,omitempty"` +} + +// swagger:enum PostStatus +// ENUM( draft, published ) +type PostStatus int16 + +// swagger:enum PostType +// ENUM( Article, Picture, Video, Audio) +type PostType int16 diff --git a/backend_v1/pkg/fields/users.gen.go b/backend_v1/pkg/fields/users.gen.go new file mode 100644 index 0000000..abfb8e9 --- /dev/null +++ b/backend_v1/pkg/fields/users.gen.go @@ -0,0 +1,241 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: - +// Revision: - +// Build Date: - +// Built By: - + +package fields + +import ( + "database/sql/driver" + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // UserStatusOk is a UserStatus of type Ok. + UserStatusOk UserStatus = iota + // UserStatusBanned is a UserStatus of type Banned. + UserStatusBanned + // UserStatusBlocked is a UserStatus of type Blocked. + UserStatusBlocked +) + +var ErrInvalidUserStatus = fmt.Errorf("not a valid UserStatus, try [%s]", strings.Join(_UserStatusNames, ", ")) + +const _UserStatusName = "okbannedblocked" + +var _UserStatusNames = []string{ + _UserStatusName[0:2], + _UserStatusName[2:8], + _UserStatusName[8:15], +} + +// UserStatusNames returns a list of possible string values of UserStatus. +func UserStatusNames() []string { + tmp := make([]string, len(_UserStatusNames)) + copy(tmp, _UserStatusNames) + return tmp +} + +// UserStatusValues returns a list of the values for UserStatus +func UserStatusValues() []UserStatus { + return []UserStatus{ + UserStatusOk, + UserStatusBanned, + UserStatusBlocked, + } +} + +var _UserStatusMap = map[UserStatus]string{ + UserStatusOk: _UserStatusName[0:2], + UserStatusBanned: _UserStatusName[2:8], + UserStatusBlocked: _UserStatusName[8:15], +} + +// String implements the Stringer interface. +func (x UserStatus) String() string { + if str, ok := _UserStatusMap[x]; ok { + return str + } + return fmt.Sprintf("UserStatus(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x UserStatus) IsValid() bool { + _, ok := _UserStatusMap[x] + return ok +} + +var _UserStatusValue = map[string]UserStatus{ + _UserStatusName[0:2]: UserStatusOk, + _UserStatusName[2:8]: UserStatusBanned, + _UserStatusName[8:15]: UserStatusBlocked, +} + +// ParseUserStatus attempts to convert a string to a UserStatus. +func ParseUserStatus(name string) (UserStatus, error) { + if x, ok := _UserStatusValue[name]; ok { + return x, nil + } + return UserStatus(0), fmt.Errorf("%s is %w", name, ErrInvalidUserStatus) +} + +var errUserStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *UserStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = UserStatus(0) + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case int64: + *x = UserStatus(v) + case string: + *x, err = ParseUserStatus(v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(v); verr == nil { + *x, err = UserStatus(val), nil + } + } + case []byte: + *x, err = ParseUserStatus(string(v)) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(string(v)); verr == nil { + *x, err = UserStatus(val), nil + } + } + case UserStatus: + *x = v + case int: + *x = UserStatus(v) + case *UserStatus: + if v == nil { + return errUserStatusNilPtr + } + *x = *v + case uint: + *x = UserStatus(v) + case uint64: + *x = UserStatus(v) + case *int: + if v == nil { + return errUserStatusNilPtr + } + *x = UserStatus(*v) + case *int64: + if v == nil { + return errUserStatusNilPtr + } + *x = UserStatus(*v) + case float64: // json marshals everything as a float64 if it's a number + *x = UserStatus(v) + case *float64: // json marshals everything as a float64 if it's a number + if v == nil { + return errUserStatusNilPtr + } + *x = UserStatus(*v) + case *uint: + if v == nil { + return errUserStatusNilPtr + } + *x = UserStatus(*v) + case *uint64: + if v == nil { + return errUserStatusNilPtr + } + *x = UserStatus(*v) + case *string: + if v == nil { + return errUserStatusNilPtr + } + *x, err = ParseUserStatus(*v) + if err != nil { + // try parsing the integer value as a string + if val, verr := strconv.Atoi(*v); verr == nil { + *x, err = UserStatus(val), nil + } + } + } + + return +} + +// Value implements the driver Valuer interface. +func (x UserStatus) Value() (driver.Value, error) { + return int64(x), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *UserStatus) Set(val string) error { + v, err := ParseUserStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *UserStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *UserStatus) Type() string { + return "UserStatus" +} + +type NullUserStatus struct { + UserStatus UserStatus + Valid bool +} + +func NewNullUserStatus(val interface{}) (x NullUserStatus) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Scan implements the Scanner interface. +func (x *NullUserStatus) Scan(value interface{}) (err error) { + if value == nil { + x.UserStatus, x.Valid = UserStatus(0), false + return + } + + err = x.UserStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullUserStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return int64(x.UserStatus), nil +} + +type NullUserStatusStr struct { + NullUserStatus +} + +func NewNullUserStatusStr(val interface{}) (x NullUserStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullUserStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.UserStatus.String(), nil +} diff --git a/backend_v1/pkg/fields/users.go b/backend_v1/pkg/fields/users.go new file mode 100644 index 0000000..c76bf28 --- /dev/null +++ b/backend_v1/pkg/fields/users.go @@ -0,0 +1,27 @@ +package fields + +import "time" + +// swagger:enum PostStatus +// ENUM( ok, banned, blocked) +type UserStatus int16 + +type UserMetas struct { + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + HeadImageUrl string `json:"head_image_url,omitempty"` + Nickname string `json:"nickname,omitempty"` + Privilege []string `json:"privilege,omitempty"` + Province string `json:"province,omitempty"` + Sex int64 `json:"sex,omitempty"` +} + +type UserAuthToken struct { + StableAccessToken string `json:"stable_access_token,omitempty"` + StableExpiresAt time.Time `json:"stable_expires_at,omitempty"` + AccessToken string `json:"access_token,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + IsSnapshotuser int64 `json:"is_snapshotuser,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} diff --git a/backend_v1/pkg/oauth/contracts.go b/backend_v1/pkg/oauth/contracts.go new file mode 100644 index 0000000..eab1177 --- /dev/null +++ b/backend_v1/pkg/oauth/contracts.go @@ -0,0 +1,12 @@ +package oauth + +import "time" + +type OAuthInfo interface { + GetOpenID() string + GetUnionID() string + GetAccessToken() string + GetRefreshToken() string + GetExpiredAt() time.Time +} + diff --git a/backend_v1/pkg/oauth/wechat.go b/backend_v1/pkg/oauth/wechat.go new file mode 100644 index 0000000..5dc97bb --- /dev/null +++ b/backend_v1/pkg/oauth/wechat.go @@ -0,0 +1,40 @@ +package oauth + +import "time" + +var _ OAuthInfo = (*WechatOAuthInfo)(nil) + +type WechatOAuthInfo struct { + Scope string `json:"scope,omitempty"` + OpenID string `json:"openid,omitempty"` + UnionID string `json:"unionid,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` +} + +// GetAccessToken implements OAuthInfo. +func (w *WechatOAuthInfo) GetAccessToken() string { + return w.AccessToken +} + +// GetExpiredAt implements OAuthInfo. +func (w *WechatOAuthInfo) GetExpiredAt() time.Time { + return time.Now().Add(time.Duration(w.ExpiresIn) * time.Second) +} + +// GetOpenID implements OAuthInfo. +func (w *WechatOAuthInfo) GetOpenID() string { + return w.OpenID +} + +// GetRefreshToken implements OAuthInfo. +func (w *WechatOAuthInfo) GetRefreshToken() string { + return w.RefreshToken +} + +// GetUnionID implements OAuthInfo. +func (w *WechatOAuthInfo) GetUnionID() string { + return w.UnionID +} + diff --git a/backend_v1/pkg/utils/exec.go b/backend_v1/pkg/utils/exec.go new file mode 100644 index 0000000..b8dd570 --- /dev/null +++ b/backend_v1/pkg/utils/exec.go @@ -0,0 +1,69 @@ +package utils + +import ( + "bufio" + "context" + "os/exec" + + "github.com/go-pay/errgroup" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// ExecCommand executes a command and streams its output in real-time +func ExecCommand(name string, args ...string) error { + log.Infof("Executing command: %s %v", name, args) + cmd := exec.Command(name, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return errors.Wrap(err, "stdout pipe error") + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return errors.Wrap(err, "stderr pipe error") + } + + if err := cmd.Start(); err != nil { + return errors.Wrap(err, "command start error") + } + + var eg errgroup.Group + eg.Go(func(ctx context.Context) error { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + log.Info(scanner.Text()) + } + return nil + }) + + eg.Go(func(ctx context.Context) error { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + log.Error(scanner.Text()) + } + return nil + }) + + if err := cmd.Wait(); err != nil { + return errors.Wrap(err, "command wait error") + } + + if err := eg.Wait(); err != nil { + return errors.Wrap(err, "command wait error") + } + + return nil +} + +// ExecCommandOutput executes a command and returns its output +func ExecCommandOutput(name string, args ...string) ([]byte, error) { + log.Infof("Executing command: %s %v", name, args) + cmd := exec.Command(name, args...) + output, err := cmd.Output() + if err != nil { + return nil, errors.Wrapf(err, "failed to execute command: %s", name) + } + return output, nil +} diff --git a/backend_v1/pkg/utils/ffmpeg.go b/backend_v1/pkg/utils/ffmpeg.go new file mode 100644 index 0000000..fd4b37f --- /dev/null +++ b/backend_v1/pkg/utils/ffmpeg.go @@ -0,0 +1,62 @@ +package utils + +import ( + "strconv" + "strings" + + "github.com/pkg/errors" +) + +func GetMediaDuration(path string) (int64, error) { + args := []string{ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path, + } + + output, err := ExecCommandOutput("ffprobe", args...) + if err != nil { + return 0, errors.Wrap(err, "ffprobe error") + } + + duration := strings.TrimSpace(string(output)) + durationFloat, err := strconv.ParseFloat(duration, 64) + if err != nil { + return 0, errors.Wrap(err, "duration conversion error") + } + + return int64(durationFloat), nil +} + +func CutMedia(input, output string, start, end int64) error { + args := []string{ + "-y", + "-hide_banner", + "-nostats", + "-v", "error", + "-ss", strconv.FormatInt(start, 10), + "-i", input, + "-t", strconv.FormatInt(end, 10), + "-c", "copy", + output, + } + + return ExecCommand("ffmpeg", args...) +} + +// GetFrameImageFromVideo extracts target time frame from a video file and saves it as an image. +func GetFrameImageFromVideo(input, output string, time int64) error { + args := []string{ + "-y", + "-hide_banner", + "-nostats", + "-v", "error", + "-i", input, + "-ss", strconv.FormatInt(time, 10), + "-vframes", "1", + output, + } + + return ExecCommand("ffmpeg", args...) +} diff --git a/backend_v1/pkg/utils/ffmpeg_test.go b/backend_v1/pkg/utils/ffmpeg_test.go new file mode 100644 index 0000000..b99665c --- /dev/null +++ b/backend_v1/pkg/utils/ffmpeg_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "path/filepath" + "testing" + + "github.com/rogeecn/fabfile" + . "github.com/smartystreets/goconvey/convey" +) + +func Test_GetMediaDuration(t *testing.T) { + Convey("test_get_media_duration", t, func() { + target := fabfile.MustFind("fixtures/input.mp4") + duration, err := GetMediaDuration(target) + So(err, ShouldBeNil) + t.Logf("duration is: %d", duration) + }) +} + +func Test_CutMedia(t *testing.T) { + Convey("test_cut_media", t, func() { + input := fabfile.MustFind("fixtures/input.mp4") + output := fabfile.MustFind("fixtures/output.mp4") + + t.Logf("INPUT: %s", input) + t.Logf("OUTPUT: %s", output) + + err := CutMedia(input, output, 0, 10) + So(err, ShouldBeNil) + + // check output duration + duration, err := GetMediaDuration(output) + So(err, ShouldBeNil) + t.Logf("output duration is: %d", duration) + }) +} + +func Test_GetFrameImageFromVideo(t *testing.T) { + Convey("test_get_frame_image_from_video", t, func() { + input := fabfile.MustFind("fixtures/input.mp4") + output := filepath.Join(filepath.Dir(input), "output.jpg") + + t.Logf("INPUT: %s", input) + t.Logf("OUTPUT: %s", output) + + err := GetFrameImageFromVideo(input, output, 1) + So(err, ShouldBeNil) + }) +} diff --git a/backend_v1/pkg/utils/fiber.go b/backend_v1/pkg/utils/fiber.go new file mode 100644 index 0000000..be1829a --- /dev/null +++ b/backend_v1/pkg/utils/fiber.go @@ -0,0 +1,19 @@ +package utils + +import ( + "net/url" + + "github.com/gofiber/fiber/v3" +) + +func FullURI(ctx fiber.Ctx) string { + fullURL := string(ctx.Request().URI().FullURI()) + u, err := url.Parse(fullURL) + if err != nil { + return "" + } + u.Scheme = ctx.Scheme() + u.Host = ctx.Host() + + return u.String() +} diff --git a/backend_v1/pkg/utils/md5.go b/backend_v1/pkg/utils/md5.go new file mode 100644 index 0000000..a782be9 --- /dev/null +++ b/backend_v1/pkg/utils/md5.go @@ -0,0 +1,42 @@ +package utils + +import ( + "crypto/md5" + "fmt" + "io" + "os" +) + +// Compare file md5 +func CompareFileMd5(file, md5 string) (bool, error) { + fileMd5, err := GetFileMd5(file) + if err != nil { + return false, err + } + return fileMd5 == md5, nil +} + +// GetFileMd5 +func GetFileMd5(file string) (string, error) { + f, err := os.Open(file) + if err != nil { + return "", err + } + defer f.Close() + + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// GetFileSize +func GetFileSize(file string) (int64, error) { + fi, err := os.Stat(file) + if err != nil { + return 0, err + } + return fi.Size(), nil +} diff --git a/backend_v1/pkg/utils/posts.go b/backend_v1/pkg/utils/posts.go new file mode 100644 index 0000000..62be5a6 --- /dev/null +++ b/backend_v1/pkg/utils/posts.go @@ -0,0 +1,45 @@ +package utils + +import "strings" + +// FormatTitle +// Format the title of a media file by replacing spaces with underscores and removing special characters +func FormatTitle(title string) string { + // remove file ext from title + if strings.Contains(title, ".") { + title = strings.Split(title, ".")[0] + } + + // replace all spaces with underscores + replacements := []string{ + " ", "", + "!", "", + "@", "", + "#", "", + "$", "", + "%", "", + "^", "", + "&", "", + "*", "", + "(", "(", + ")", ")", + "[", "【", + "]", "】", + "{", "《", + "}", "》", + ":", ":", + ";", "", + "'", "", + "\"", "", + "<", "", + ">", "", + ",", "", + ".", "", + "?", "", + } + + replacer := strings.NewReplacer(replacements...) + title = replacer.Replace(title) + + return title +} diff --git a/backend_v1/pkg/utils/random_name.go b/backend_v1/pkg/utils/random_name.go new file mode 100644 index 0000000..772e06c --- /dev/null +++ b/backend_v1/pkg/utils/random_name.go @@ -0,0 +1,25 @@ +package utils + +import ( + "fmt" + "math/rand" +) + +type Names []string + +func (names Names) Random() string { + randIdx := rand.Intn(len(names)) + return names[randIdx] +} + +var names Names = []string{"淡疤", "青衫", "木兮", "念心", "桃夭", "容烟", "墨兮", "谷槐", "宿命", "凝芸", "佑尘", "夏烟", "笑眸", "荼靡", "金风", "千儿", "东来", "怪人", "韵佳", "奋进", "拾碎", "篱觞", "歌集", "离染", "囚宠", "亡命", "岑辞", "锦鑫", "旧颜", "沐曦", "冰柠", "深秋", "白泽", "婉安", "高玄", "风靖", "樚夏", "寄风", "墨尘", "栀虞", "柒墨", "泪痣", "小蕾", "情痴", "卮言", "尘封", "软水", "修远", "凉梦", "未末", "凉曦", "喵喵", "朱砂", "夏桑", "孤痞", "狗崽", "星魂", "独悲", "蔷薇", "如烟", "鸢尾", "香萱", "封禹", "凝安", "缱绻", "晴栀", "橘鸢", "花祭", "冷巷", "昔瞳", "冰海", "咕嘟", "古巷", "邪少", "浅沫", "蝶汐", "寂弦", "傲娇", "璃染", "薄姬", "绾生", "鲸年", "凉眸", "瑛蔓", "饿货", "南語", "明熙", "与否", "陌离", "菡璐", "寒霜", "屿卿", "君故", "苦撑", "秇淰", "余温", "昊昌", "梦露", "月溪", "楚荆", "共游", "夏木", "旭姿", "和原", "霄鸣", "弱陷", "迷魂", "忘忧", "落凝", "情渣", "空心", "厌等", "孤雁", "文昌", "寻梅", "北辰", "玄裳", "尚宏", "血欲", "渡难", "梦瑶", "思如", "陌路", "新词", "斑驳", "溪澈", "梦寒", "北萧", "楚昱", "归处", "龙方", "花葬", "翰玉", "墨染", "芈荨", "轩明", "南珍", "白亦", "玉露", "弥生", "泞明", "晓欢", "锦程", "沐子", "柠木", "淡忘", "瑜采", "祁梦", "花姬", "白芷", "觅眸", "晴天", "玖辞", "流觞", "鸢语", "北宛", "旭阳", "喪鐘", "玉燕", "信光", "亦初", "思辰", "孤妄", "堇年", "凉城", "风起", "盼星", "长卿", "惬允", "烟花", "思她", "婉兮", "傲柔", "柠溪", "诺语", "星落", "红烛", "炙雪", "驭王", "悠闲", "静柏", "芷云", "薄年", "暮雪", "觅儿", "初妍", "友灵", "羽蔷", "酒笙", "陌语", "稚北", "清斐", "千尘", "女笙", "月棠", "信笺", "旋翊", "逐风", "葬爱", "弑天", "言止", "清浅", "素言", "烟光", "宦妃", "浮沉", "乐果", "情殇", "滥情", "断殇", "孤隐", "清澈", "昔望", "雅琳", "洛雅", "苏塔", "木瑾", "俗野", "清风", "未眠", "醉音", "德旭", "歆珊", "初遇", "素笺", "心事", "思松", "落栀", "哆哆", "超渡", "寡欲", "雅儿", "墨池", "扎心", "青栀", "怀莲", "幻影", "孤傲", "花漓", "弃棋", "执念", "云湮", "善韶", "稚女", "舟潇", "紸啶", "丁丁", "故人", "洛锦", "离筱", "人生", "展眉", "堪阳", "凉墨", "浅瞳", "屿风", "堇夏", "凝萱", "棱人", "青袂", "雁卉", "蚀妆", "失語", "迎天", "和好", "软趴", "唯美", "故与", "归安", "舒曼", "纯疯", "如梦", "梦醒", "执扇", "玉枫", "暖瞳", "暖暖", "白骨", "素贞", "矯情", "亦依", "余笙", "沫夏", "尘笙", "梦幻", "鱼柒", "同行", "柠栀", "航盛", "睿暄", "竹隐", "昌胤", "暮昼", "朔翌", "楚黛", "未央", "笺城", "君兮", "淡墨", "航瀚", "荼蘼", "青橙", "海秋", "琼彤", "梦香", "小之", "驯悍", "初原", "易泉", "又蓝", "赴星", "英发", "椿湫", "兮心", "吢丕", "如袖", "青稚", "雁露", "宠我", "腻歪", "失依", "阡陌", "钦睿", "恨寒", "蓝桉", "唇钉", "及巳", "残年", "暮雨", "半兰", "月痕", "问芙", "尛滊", "孤毒", "青笺", "婉儿", "昔年", "飞珍", "青枫", "如初", "喜笑", "壮志", "执妄", "墨颜", "昕月", "涵易", "浩伊", "映真", "颜霜", "怪我", "裴煜", "寺瑾", "昀池", "往事", "烟瘾", "南陌", "快乐", "情疏", "惟清", "羽欺", "箫墨", "顾安", "柔骨", "茶栀", "悲切", "沉世", "归来", "倾舞", "赴宴", "宁夏", "盛雅", "安柏", "惜梦", "沫楹", "飞兰", "枫宸", "蒹葭", "宛菡", "琬茵", "妙臾", "凌薇", "初見", "温柔", "故城", "冰夏", "糖豆", "月吉", "春秋", "席城", "饮鸿", "忘卿", "浮夸", "赘语", "木槿", "良棋", "雨昕", "荷娜", "浅阳", "凌文", "德泽", "书兰", "安夏", "兮颜", "雅痞", "孤祭", "暮寒", "余欢", "广志", "彼岸", "柠萌", "崩溃", "心逸", "念梦", "安筠", "浮笙", "北陌", "谜乱", "梦山", "溺渁", "遗忘", "华元", "唯卿", "文凡", "眠空", "清酒", "记得", "司深", "苦涩", "独酌", "雪巧", "泠崖", "暖茶", "执顔", "初尘", "谷野", "南鸢", "青漫", "情癌", "孤心", "芯恬", "于归", "樱九", "千鹤", "墨离", "猫籹", "鉽探", "部忆", "烟柳", "知否", "北执", "清念", "南辞", "苏北", "瑾色", "寒风", "牵缕", "绯凉", "飞烟", "挼蓝", "宁良", "谦贞", "别枳", "程雨", "续写", "茶芈", "淡妆", "余了", "浅伤", "向山", "暮涛", "从凝", "安知", "慕山", "楚暮", "酒涩", "君情", "软糖", "瑶苡", "逃疫", "浅婼", "辜屿", "青裁", "夏夜", "空笑", "信念", "泠鸢", "忆香", "颜绯", "遇見", "流晞", "曦光", "青琯", "尘墨", "墨深", "若溪", "立云", "腻了", "孤酒", "洛凡", "南风", "北念", "轩擎", "靳默", "楓葉", "弦音", "虚妄", "煊洛", "吟留", "孤者", "拓晨", "泪珠", "淡然", "唯愛", "明媚", "希年", "衫青", "妙芙", "云归", "勿扰", "熙鸿", "善变", "沁殇", "情执", "流霜", "辰炎", "迷茫", "难瘦", "怼烎", "茶靡", "夜眠", "樱凌", "水墨", "禹礼", "玲珑", "孤风", "冰洛", "洛水", "雅青", "青丝", "疏素", "昕颖", "暴君", "流仙", "扮乖", "曼安", "南湘", "雪敏", "所幸", "初衷", "郁郁", "透骨", "繁花", "文祥", "熹微", "赴约", "落蝶", "简清", "嫑走", "朝雾", "新竹", "残温", "皓明", "凉笙", "初懵", "余阳", "暮想", "落英", "明博", "宇睿", "香旋", "傲冬", "昊振", "暮念", "幻露", "陌槿", "冰岚", "宥谦", "風鈴", "白越", "季渊", "木屋", "余悸", "十染", "沐风", "江山", "敏感", "祭音", "汐颜", "清绾", "少言", "咔嚓", "洛羽", "子桑", "君墨", "书琴", "倦怠", "枯骨", "寒思", "俗欲", "温茶", "栀夏", "安逸", "青鸢", "寄翠", "海鹤", "左岸", "羽辰", "喵叽", "博文", "紫陌", "夏栀", "粗芒", "瑞尚", "荒島", "临渊", "冠高", "栀蓝", "青灯", "迷梦", "冉七", "与清", "墨城", "柒婳", "炫永", "忆白", "般若", "瀚伯", "绿兮", "慕宇", "葬情", "迟醉", "贤思", "屿里", "念辰", "断弦", "谷翠", "不羁", "梦桃", "鹏昌", "烟凉", "芷若", "惊鸿", "凝冬", "浅枔", "懿叶", "嗯哼", "季妩", "慕阳", "清络", "宇捷", "左颜", "暮年", "祥余", "川奈", "凌丝", "羽西", "抚弦", "星河", "采蓝", "西木", "芄孒", "冰蝶", "和昊", "青城", "无洛", "薄幸", "归人", "素眉", "酥心", "漠尘", "素衣", "北幽", "爱尽", "冷菱", "美痞", "栖川", "千西", "薪蔓", "醉兮", "夏枳", "枕夢", "苍穹", "枫羽", "斷點", "野心", "兰若", "清欢", "痴货", "彤欢", "沐清", "灼华", "若丝", "墨痕", "难眠", "残音", "九凝", "曲尤", "思柔", "左眸", "优柔", "风韵", "回音", "画未", "语梦", "盼月", "薄凉", "轻袭", "白逸", "今天", "晓筠", "振恒", "凉兮", "璃安", "月眠", "落棠", "天空", "念安", "北眼", "敬凡", "川芎", "陌寒", "眉黛", "若雨", "飞荷", "尔烟", "诗玲", "七眠", "琉璃", "酒坟", "留井", "初心", "鲸落", "明轩", "冷瞳", "西岭", "鹤归", "碧落", "情票", "矜言", "上卿", "晓梅", "尔竹", "高歌", "纠纷", "陌念", "安双", "暗喜", "樱子", "子默", "陌然", "盼香", "月魄", "天使", "子无", "慢热", "胜晨", "晴雨", "萌酱", "新生", "虐心", "月冥", "狼狈", "枳夏", "措辞", "南念", "眼热", "阔以", "念你", "御音", "酒尽", "颜柒", "雨安", "川暮", "辰希", "殇项", "萧馨", "墨言", "宁初", "七巷", "初阳", "星宿", "若白", "访文", "夏青", "依云", "余溫", "幼榕", "枕畔", "微桐", "烟北", "软语", "磉愁", "淡雅", "飞南", "残訫", "南溪", "寡淡", "硬撑", "囚念", "命名", "掌心", "墨晔", "洛厚", "轻烟", "嘉笙", "傲晴", "凉生", "魅眸", "殇夏", "羽翼", "断桥", "迷妹", "麋鲨", "北梦", "流浪", "双臂", "阔别", "昕无", "熙妍", "染昔", "微尘", "白慕", "温眉", "忘川", "夙愿", "蓝荏", "辞玖", "钧晨", "馨妤", "若兮", "祭夢", "离骚", "夜雨", "高升", "空瞳", "晚枫", "南夕", "嫁风", "浅唱", "花吻", "楚珞", "垂眸", "桃墅", "归季", "浩玮", "梦菡", "途往", "智慧", "安白", "签名", "疚爱", "惗旧", "雪娴", "揽月", "柠檬", "蝶衣", "北顾", "稚鬼", "玉锵", "明君", "火夏", "贼婆", "笙漓", "丹珍", "胡巴", "静谧", "浅墨", "彼端", "随性", "紫萱", "栖风", "陌沫", "沐樱", "厌你", "微雨", "语蝶", "青柠", "温婉", "子归", "橘凉", "橘莘", "碍人", "乐腾", "宜修", "勉励", "庆云", "晓山", "夏山", "葬魂", "如风", "璐曦", "屿暖", "青迟", "悸动", "初顾", "思源", "放生", "厌世", "逸玥", "叙白", "舟摇", "臾凉", "冷眸", "超越", "安梦", "北屿", "依霜", "拾卿", "盼眉", "念夏", "藍凋", "君离", "瑾凉", "软祣", "山河", "茶白", "双生", "昨天", "黛儿", "浅陌", "花醉", "归栀", "南村", "鸢浅", "怪咖", "梦碎", "栋倍", "牵绊", "恩怜", "孤影", "最后", "莫忘", "流年", "鹤汀", "花影", "沐槿", "暮光", "暮风", "翠微", "曼雁", "柚屿", "殇夢", "默笙", "墨昕", "绿兰", "笑拥", "孤城", "眠锦", "笨笨", "游辰", "夏寒", "雅静", "辞寒", "南苼", "婓冠", "青酒", "夙缘", "浣溪", "南栀", "英杰", "三水", "渡難", "涵双", "痕至", "公子", "园芷", "尔雅", "流转", "隐欲", "焚琴", "淺夏", "和漾", "执着", "陌默", "冷暖", "阑珊", "现安", "澜沧", "翊旋", "安陌", "僚兮", "志行", "芊眠", "林何", "奶猫", "言司", "初雨", "傲寒", "闻柳", "忆忧", "千羽", "旧识", "雪丽", "烟客", "厌旧", "清茶", "初歌", "隐痛", "馋喵", "南笙", "迅言", "渡九"} + +func RandomNickname() string { + name1 := names.Random() + name2 := names.Random() + return name1 + "的" + name2 +} + +func RandomAvatar() string { + return fmt.Sprintf("/avatar/%d.jpeg", 1+rand.Intn(79)) +} diff --git a/backend_v1/pkg/utils/random_name_test.go b/backend_v1/pkg/utils/random_name_test.go new file mode 100644 index 0000000..ed3aba1 --- /dev/null +++ b/backend_v1/pkg/utils/random_name_test.go @@ -0,0 +1,11 @@ +package utils + +import "testing" + +func TestNames_Random(t *testing.T) { + for i := 0; i < 10; i++ { + + name := RandomNickname() + t.Logf("name: %s", name) + } +} diff --git a/backend_v1/providers/ali/config.go b/backend_v1/providers/ali/config.go new file mode 100644 index 0000000..f4b8b1d --- /dev/null +++ b/backend_v1/providers/ali/config.go @@ -0,0 +1,56 @@ +package ali + +import ( + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +const DefaultPrefix = "Ali" + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{ + opt.Prefix(DefaultPrefix), + }, + } +} + +type Config struct { + AccessKeyId string + AccessKeySecret string + Bucket string + Region string + Host *string + CallbackURL string +} + +func Provide(opts ...opt.Option) error { + o := opt.New(opts...) + var config Config + if err := o.UnmarshalConfig(&config); err != nil { + return err + } + + return container.Container.Provide(func() (*OSSClient, error) { + cred := credentials.NewStaticCredentialsProvider(config.AccessKeyId, config.AccessKeySecret) + + cfg := oss.LoadDefaultConfig(). + WithCredentialsProvider(cred). + WithRegion(config.Region). + WithUseCName(true). + WithEndpoint(*config.Host) + + cfgInternal := oss.LoadDefaultConfig(). + WithCredentialsProvider(cred). + WithRegion(config.Region) + + return &OSSClient{ + client: oss.NewClient(cfg), + internalClient: oss.NewClient(cfgInternal), + config: &config, + }, nil + }, o.DiOptions()...) +} diff --git a/backend_v1/providers/ali/oss_client.go b/backend_v1/providers/ali/oss_client.go new file mode 100644 index 0000000..b296f39 --- /dev/null +++ b/backend_v1/providers/ali/oss_client.go @@ -0,0 +1,165 @@ +package ali + +import ( + "context" + "path/filepath" + "strings" + "time" + + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" +) + +type OSSOption func(*OSSOptions) + +type OSSOptions struct { + internal bool + expire *time.Duration +} + +func WithExpire(expire time.Duration) OSSOption { + return func(o *OSSOptions) { + o.expire = &expire + } +} + +func WithInternal() OSSOption { + return func(o *OSSOptions) { + o.internal = true + } +} + +type OSSClient struct { + client *oss.Client + internalClient *oss.Client + config *Config +} + +func (c *OSSClient) GetSavePath(path string) string { + return filepath.Join("quyun", strings.Trim(path, "/")) +} + +func (c *OSSClient) GetClient() *oss.Client { + return c.client +} + +func (c *OSSClient) PreSignUpload(ctx context.Context, path, mimeType string, opts ...OSSOption) (*oss.PresignResult, error) { + request := &oss.PutObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(c.GetSavePath(path)), + ContentType: oss.Ptr(mimeType), + } + opt := &OSSOptions{} + for _, o := range opts { + o(opt) + } + + client := c.client + if opt.internal { + client = c.internalClient + } + + return client.Presign(ctx, request) +} + +func (c *OSSClient) Download(ctx context.Context, path, dest string, opts ...OSSOption) error { + request := &oss.GetObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(path), + } + + opt := &OSSOptions{} + for _, o := range opts { + o(opt) + } + + client := c.client + if opt.internal { + client = c.internalClient + } + + ossFuncs := []func(*oss.Options){ + oss.OpReadWriteTimeout(time.Minute * 20), + } + if _, err := client.GetObjectToFile(ctx, request, dest, ossFuncs...); err != nil { + return err + } + return nil +} + +// GetSignedUrl +func (c *OSSClient) GetSignedUrl(ctx context.Context, path string, opts ...OSSOption) (string, error) { + request := &oss.GetObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(path), + } + + opt := &OSSOptions{} + for _, o := range opts { + o(opt) + } + + expire := time.Minute * 5 + if opt.expire != nil { + expire = *opt.expire + } + + client := c.client + if opt.internal { + client = c.internalClient + } + + preSign, err := client.Presign(ctx, request, oss.PresignExpires(expire)) + if err != nil { + return "", err + } + return preSign.URL, nil +} + +// Delete +func (c *OSSClient) Delete(ctx context.Context, path string, opts ...OSSOption) error { + request := &oss.DeleteObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(path), + } + + opt := &OSSOptions{} + for _, o := range opts { + o(opt) + } + + client := c.client + if opt.internal { + client = c.internalClient + } + + if _, err := client.DeleteObject(ctx, request); err != nil { + return err + } + return nil +} + +// Upload +func (c *OSSClient) Upload(ctx context.Context, input, dst string, opts ...OSSOption) error { + request := &oss.PutObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(dst), + } + + opt := &OSSOptions{} + for _, o := range opts { + o(opt) + } + + client := c.client + if opt.internal { + client = c.internalClient + } + + ossFuncs := []func(*oss.Options){ + oss.OpReadWriteTimeout(time.Minute * 20), + } + if _, err := client.PutObjectFromFile(ctx, request, input, ossFuncs...); err != nil { + return err + } + return nil +} diff --git a/backend_v1/providers/app/config.go b/backend_v1/providers/app/config.go index 1d1d204..15aac2d 100644 --- a/backend_v1/providers/app/config.go +++ b/backend_v1/providers/app/config.go @@ -21,9 +21,13 @@ func DefaultProvider() container.ProviderContainer { type AppMode string type Config struct { - Mode AppMode - Cert *Cert - BaseURI *string + Mode AppMode + Cert *Cert + BaseURI *string + StoragePath string + DistAdmin string + DistWeChat string + RechargeWechat string } func (c *Config) IsDevMode() bool { diff --git a/backend_v1/providers/app/config.go.bak b/backend_v1/providers/app/config.go.bak new file mode 100644 index 0000000..1d1d204 --- /dev/null +++ b/backend_v1/providers/app/config.go.bak @@ -0,0 +1,45 @@ +package app + +import ( + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +const DefaultPrefix = "App" + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{ + opt.Prefix(DefaultPrefix), + }, + } +} + +// swagger:enum AppMode +// ENUM(development, release, test) +type AppMode string + +type Config struct { + Mode AppMode + Cert *Cert + BaseURI *string +} + +func (c *Config) IsDevMode() bool { + return c.Mode == AppModeDevelopment +} + +func (c *Config) IsReleaseMode() bool { + return c.Mode == AppModeRelease +} + +func (c *Config) IsTestMode() bool { + return c.Mode == AppModeTest +} + +type Cert struct { + CA string + Cert string + Key string +} diff --git a/backend_v1/providers/req/client.go b/backend_v1/providers/req/client.go new file mode 100644 index 0000000..c580c43 --- /dev/null +++ b/backend_v1/providers/req/client.go @@ -0,0 +1,152 @@ +package req + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "quyun/v2/providers/req/cookiejar" + + "github.com/imroc/req/v3" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +type Client struct { + client *req.Client + jar *cookiejar.Jar +} + +func Provide(opts ...opt.Option) error { + o := opt.New(opts...) + var config Config + if err := o.UnmarshalConfig(&config); err != nil { + return err + } + return container.Container.Provide(func() (*Client, error) { + c := &Client{} + + client := req.C() + if config.DevMode { + client.DevMode() + } + + if config.CookieJarFile != "" { + dir := filepath.Dir(config.CookieJarFile) + if _, err := os.Stat(dir); os.IsNotExist(err) { + err = os.MkdirAll(dir, 0o755) + if err != nil { + return nil, err + } + } + jar, err := cookiejar.New(&cookiejar.Options{ + Filename: config.CookieJarFile, + }) + if err != nil { + return nil, err + } + + c.jar = jar + client.SetCookieJar(jar) + } + + if config.RootCa != nil { + client.SetRootCertsFromFile(config.RootCa...) + } + + if config.InsecureSkipVerify { + client.EnableInsecureSkipVerify() + } + + if config.UserAgent != "" { + client.SetUserAgent(config.UserAgent) + } + if config.Timeout > 0 { + client.SetTimeout(time.Duration(config.Timeout) * time.Second) + } + + if config.CommonHeaders != nil { + client.SetCommonHeaders(config.CommonHeaders) + } + + if config.AuthBasic.Username != "" && config.AuthBasic.Password != "" { + client.SetCommonBasicAuth(config.AuthBasic.Username, config.AuthBasic.Password) + } + + if config.AuthBearerToken != "" { + client.SetCommonBearerAuthToken(config.AuthBearerToken) + } + + if config.ProxyURL != "" { + client.SetProxyURL(config.ProxyURL) + } + + if config.RedirectPolicy != nil { + client.SetRedirectPolicy(parsePolicies(config.RedirectPolicy)...) + } + + c.client = client + return c, nil + }, o.DiOptions()...) +} + +func parsePolicies(policies []string) []req.RedirectPolicy { + ps := []req.RedirectPolicy{} + for _, policy := range policies { + policyItems := strings.Split(policy, ":") + if len(policyItems) != 2 { + continue + } + + switch policyItems[0] { + case "Max": + max, err := strconv.Atoi(policyItems[1]) + if err != nil { + continue + } + ps = append(ps, req.MaxRedirectPolicy(max)) + case "No": + ps = append(ps, req.NoRedirectPolicy()) + case "SameDomain": + ps = append(ps, req.SameDomainRedirectPolicy()) + case "SameHost": + ps = append(ps, req.SameHostRedirectPolicy()) + case "AllowedHost": + ps = append(ps, req.AllowedHostRedirectPolicy(strings.Split(policyItems[1], ",")...)) + case "AllowedDomain": + ps = append(ps, req.AllowedDomainRedirectPolicy(strings.Split(policyItems[1], ",")...)) + } + } + + return ps +} + +func (c *Client) R() *req.Request { + return c.client.R() +} + +func (c *Client) SaveCookJar() error { + return c.jar.Save() +} + +func (c *Client) GetCookie(key string) (string, bool) { + kv := c.AllCookiesKV() + v, ok := kv[key] + return v, ok +} + +func (c *Client) AllCookies() []*http.Cookie { + return c.jar.AllCookies() +} + +func (c *Client) AllCookiesKV() map[string]string { + return c.jar.KVData() +} + +func (c *Client) SetCookie(u *url.URL, cookies []*http.Cookie) { + c.jar.SetCookies(u, cookies) +} diff --git a/backend_v1/providers/req/config.go b/backend_v1/providers/req/config.go new file mode 100644 index 0000000..6967681 --- /dev/null +++ b/backend_v1/providers/req/config.go @@ -0,0 +1,34 @@ +package req + +import ( + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +const DefaultPrefix = "HttpClient" + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{ + opt.Prefix(DefaultPrefix), + }, + } +} + +type Config struct { + DevMode bool + CookieJarFile string + RootCa []string + UserAgent string + InsecureSkipVerify bool + CommonHeaders map[string]string + Timeout uint + AuthBasic struct { + Username string + Password string + } + AuthBearerToken string + ProxyURL string + RedirectPolicy []string // "Max:10;No;SameDomain;SameHost;AllowedHost:x,x,x,x,x,AllowedDomain:x,x,x,x,x" +} diff --git a/backend_v1/providers/req/cookiejar/jar.go b/backend_v1/providers/req/cookiejar/jar.go new file mode 100644 index 0000000..1208aad --- /dev/null +++ b/backend_v1/providers/req/cookiejar/jar.go @@ -0,0 +1,704 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cookiejar implements an in-memory RFC 6265-compliant http.CookieJar. +// +// This implementation is a fork of net/http/cookiejar which also +// implements methods for dumping the cookies to persistent +// storage and retrieving them. +package cookiejar + +import ( + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + "golang.org/x/net/publicsuffix" +) + +// PublicSuffixList provides the public suffix of a domain. For example: +// - the public suffix of "example.com" is "com", +// - the public suffix of "foo1.foo2.foo3.co.uk" is "co.uk", and +// - the public suffix of "bar.pvt.k12.ma.us" is "pvt.k12.ma.us". +// +// Implementations of PublicSuffixList must be safe for concurrent use by +// multiple goroutines. +// +// An implementation that always returns "" is valid and may be useful for +// testing but it is not secure: it means that the HTTP server for foo.com can +// set a cookie for bar.com. +// +// A public suffix list implementation is in the package +// golang.org/x/net/publicsuffix. +type PublicSuffixList interface { + // PublicSuffix returns the public suffix of domain. + // + // TODO: specify which of the caller and callee is responsible for IP + // addresses, for leading and trailing dots, for case sensitivity, and + // for IDN/Punycode. + PublicSuffix(domain string) string + + // String returns a description of the source of this public suffix + // list. The description will typically contain something like a time + // stamp or version number. + String() string +} + +// Options are the options for creating a new Jar. +type Options struct { + // PublicSuffixList is the public suffix list that determines whether + // an HTTP server can set a cookie for a domain. + // + // If this is nil, the public suffix list implementation in golang.org/x/net/publicsuffix + // is used. + PublicSuffixList PublicSuffixList + + // Filename holds the file to use for storage of the cookies. + // If it is empty, the value of DefaultCookieFile will be used. + Filename string + + // NoPersist specifies whether no persistence should be used + // (useful for tests). If this is true, the value of Filename will be + // ignored. + NoPersist bool +} + +// Jar implements the http.CookieJar interface from the net/http package. +type Jar struct { + // filename holds the file that the cookies were loaded from. + filename string + + psList PublicSuffixList + + // mu locks the remaining fields. + mu sync.Mutex + + // entries is a set of entries, keyed by their eTLD+1 and subkeyed by + // their name/domain/path. + entries map[string]map[string]entry +} + +var noOptions Options + +// New returns a new cookie jar. A nil *Options is equivalent to a zero +// Options. +// +// New will return an error if the cookies could not be loaded +// from the file for any reason than if the file does not exist. +func New(o *Options) (*Jar, error) { + return newAtTime(o, time.Now()) +} + +// newAtTime is like New but takes the current time as a parameter. +func newAtTime(o *Options, now time.Time) (*Jar, error) { + jar := &Jar{ + entries: make(map[string]map[string]entry), + } + if o == nil { + o = &noOptions + } + if jar.psList = o.PublicSuffixList; jar.psList == nil { + jar.psList = publicsuffix.List + } + if !o.NoPersist { + if jar.filename = o.Filename; jar.filename == "" { + jar.filename = DefaultCookieFile() + } + if err := jar.load(); err != nil { + return nil, errors.Wrap(err, "cannot load cookies") + } + } + jar.deleteExpired(now) + return jar, nil +} + +// homeDir returns the OS-specific home path as specified in the environment. +func homeDir() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH")) + } + return os.Getenv("HOME") +} + +// entry is the internal representation of a cookie. +// +// This struct type is not used outside of this package per se, but the exported +// fields are those of RFC 6265. +// Note that this structure is marshaled to JSON, so backward-compatibility +// should be preserved. +type entry struct { + Name string + Value string + Domain string + Path string + Secure bool + HttpOnly bool + Persistent bool + HostOnly bool + Expires time.Time + Creation time.Time + LastAccess time.Time + + // Updated records when the cookie was updated. + // This is different from creation time because a cookie + // can be changed without updating the creation time. + Updated time.Time + + // CanonicalHost stores the original canonical host name + // that the cookie was associated with. We store this + // so that even if the public suffix list changes (for example + // when storing/loading cookies) we can still get the correct + // jar keys. + CanonicalHost string +} + +// id returns the domain;path;name triple of e as an id. +func (e *entry) id() string { + return id(e.Domain, e.Path, e.Name) +} + +// id returns the domain;path;name triple as an id. +func id(domain, path, name string) string { + return fmt.Sprintf("%s;%s;%s", domain, path, name) +} + +// shouldSend determines whether e's cookie qualifies to be included in a +// request to host/path. It is the caller's responsibility to check if the +// cookie is expired. +func (e *entry) shouldSend(https bool, host, path string) bool { + return e.domainMatch(host) && e.pathMatch(path) && (https || !e.Secure) +} + +// domainMatch implements "domain-match" of RFC 6265 section 5.1.3. +func (e *entry) domainMatch(host string) bool { + if e.Domain == host { + return true + } + return !e.HostOnly && hasDotSuffix(host, e.Domain) +} + +// pathMatch implements "path-match" according to RFC 6265 section 5.1.4. +func (e *entry) pathMatch(requestPath string) bool { + if requestPath == e.Path { + return true + } + if strings.HasPrefix(requestPath, e.Path) { + if e.Path[len(e.Path)-1] == '/' { + return true // The "/any/" matches "/any/path" case. + } else if requestPath[len(e.Path)] == '/' { + return true // The "/any" matches "/any/path" case. + } + } + return false +} + +// hasDotSuffix reports whether s ends in "."+suffix. +func hasDotSuffix(s, suffix string) bool { + return len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.' && s[len(s)-len(suffix):] == suffix +} + +type byCanonicalHost struct { + byPathLength +} + +func (s byCanonicalHost) Less(i, j int) bool { + e0, e1 := &s.byPathLength[i], &s.byPathLength[j] + if e0.CanonicalHost != e1.CanonicalHost { + return e0.CanonicalHost < e1.CanonicalHost + } + return s.byPathLength.Less(i, j) +} + +// byPathLength is a []entry sort.Interface that sorts according to RFC 6265 +// section 5.4 point 2: by longest path and then by earliest creation time. +type byPathLength []entry + +func (s byPathLength) Len() int { return len(s) } + +func (s byPathLength) Less(i, j int) bool { + e0, e1 := &s[i], &s[j] + if len(e0.Path) != len(e1.Path) { + return len(e0.Path) > len(e1.Path) + } + if !e0.Creation.Equal(e1.Creation) { + return e0.Creation.Before(e1.Creation) + } + // The following are not strictly necessary + // but are useful for providing deterministic + // behaviour in tests. + if e0.Name != e1.Name { + return e0.Name < e1.Name + } + return e0.Value < e1.Value +} + +func (s byPathLength) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// Cookies implements the Cookies method of the http.CookieJar interface. +// +// It returns an empty slice if the URL's scheme is not HTTP or HTTPS. +func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) { + return j.cookies(u, time.Now()) +} + +// cookies is like Cookies but takes the current time as a parameter. +func (j *Jar) cookies(u *url.URL, now time.Time) (cookies []*http.Cookie) { + if u.Scheme != "http" && u.Scheme != "https" { + return cookies + } + host, err := canonicalHost(u.Host) + if err != nil { + return cookies + } + key := jarKey(host, j.psList) + + j.mu.Lock() + defer j.mu.Unlock() + + submap := j.entries[key] + if submap == nil { + return cookies + } + + https := u.Scheme == "https" + path := u.Path + if path == "" { + path = "/" + } + + var selected []entry + for id, e := range submap { + if !e.Expires.After(now) { + // Save some space by deleting the value when the cookie + // expires. We can't delete the cookie itself because then + // we wouldn't know that the cookie had expired when + // we merge with another cookie jar. + if e.Value != "" { + e.Value = "" + submap[id] = e + } + continue + } + if !e.shouldSend(https, host, path) { + continue + } + e.LastAccess = now + submap[id] = e + selected = append(selected, e) + } + + sort.Sort(byPathLength(selected)) + for _, e := range selected { + cookies = append(cookies, &http.Cookie{Name: e.Name, Value: e.Value}) + } + + return cookies +} + +// AllCookies returns all cookies in the jar. The returned cookies will +// have Domain, Expires, HttpOnly, Name, Secure, Path, and Value filled +// out. Expired cookies will not be returned. This function does not +// modify the cookie jar. +func (j *Jar) AllCookies() (cookies []*http.Cookie) { + return j.allCookies(time.Now()) +} + +// allCookies is like AllCookies but takes the current time as a parameter. +func (j *Jar) allCookies(now time.Time) []*http.Cookie { + var selected []entry + j.mu.Lock() + defer j.mu.Unlock() + for _, submap := range j.entries { + for _, e := range submap { + if !e.Expires.After(now) { + // Do not return expired cookies. + continue + } + selected = append(selected, e) + } + } + + sort.Sort(byCanonicalHost{byPathLength(selected)}) + cookies := make([]*http.Cookie, len(selected)) + for i, e := range selected { + // Note: The returned cookies do not contain sufficient + // information to recreate the database. + cookies[i] = &http.Cookie{ + Name: e.Name, + Value: e.Value, + Path: e.Path, + Domain: e.Domain, + Expires: e.Expires, + Secure: e.Secure, + HttpOnly: e.HttpOnly, + } + } + + return cookies +} + +// RemoveCookie removes the cookie matching the name, domain and path +// specified by c. +func (j *Jar) RemoveCookie(c *http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + id := id(c.Domain, c.Path, c.Name) + key := jarKey(c.Domain, j.psList) + if e, ok := j.entries[key][id]; ok { + e.Value = "" + e.Expires = time.Now().Add(-1 * time.Second) + j.entries[key][id] = e + } +} + +// merge merges all the given entries into j. More recently changed +// cookies take precedence over older ones. +func (j *Jar) merge(entries []entry) { + for _, e := range entries { + if e.CanonicalHost == "" { + continue + } + key := jarKey(e.CanonicalHost, j.psList) + id := e.id() + submap := j.entries[key] + if submap == nil { + j.entries[key] = map[string]entry{ + id: e, + } + continue + } + oldEntry, ok := submap[id] + if !ok || e.Updated.After(oldEntry.Updated) { + submap[id] = e + } + } +} + +var expiryRemovalDuration = 24 * time.Hour + +// deleteExpired deletes all entries that have expired for long enough +// that we can actually expect there to be no external copies of it that +// might resurrect the dead cookie. +func (j *Jar) deleteExpired(now time.Time) { + for tld, submap := range j.entries { + for id, e := range submap { + if !e.Expires.After(now) && !e.Updated.Add(expiryRemovalDuration).After(now) { + delete(submap, id) + } + } + if len(submap) == 0 { + delete(j.entries, tld) + } + } +} + +// RemoveAllHost removes any cookies from the jar that were set for the given host. +func (j *Jar) RemoveAllHost(host string) { + host, err := canonicalHost(host) + if err != nil { + return + } + key := jarKey(host, j.psList) + + j.mu.Lock() + defer j.mu.Unlock() + + expired := time.Now().Add(-1 * time.Second) + submap := j.entries[key] + for id, e := range submap { + if e.CanonicalHost == host { + // Save some space by deleting the value when the cookie + // expires. We can't delete the cookie itself because then + // we wouldn't know that the cookie had expired when + // we merge with another cookie jar. + e.Value = "" + e.Expires = expired + submap[id] = e + } + } +} + +// RemoveAll removes all the cookies from the jar. +func (j *Jar) RemoveAll() { + expired := time.Now().Add(-1 * time.Second) + j.mu.Lock() + defer j.mu.Unlock() + for _, submap := range j.entries { + for id, e := range submap { + // Save some space by deleting the value when the cookie + // expires. We can't delete the cookie itself because then + // we wouldn't know that the cookie had expired when + // we merge with another cookie jar. + e.Value = "" + e.Expires = expired + submap[id] = e + } + } +} + +// SetCookies implements the SetCookies method of the http.CookieJar interface. +// +// It does nothing if the URL's scheme is not HTTP or HTTPS. +func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.setCookies(u, cookies, time.Now()) +} + +// setCookies is like SetCookies but takes the current time as parameter. +func (j *Jar) setCookies(u *url.URL, cookies []*http.Cookie, now time.Time) { + if len(cookies) == 0 { + return + } + if u.Scheme != "http" && u.Scheme != "https" { + // TODO is this really correct? It might be nice to send + // cookies to websocket connections, for example. + return + } + host, err := canonicalHost(u.Host) + if err != nil { + return + } + key := jarKey(host, j.psList) + defPath := defaultPath(u.Path) + + j.mu.Lock() + defer j.mu.Unlock() + + submap := j.entries[key] + for _, cookie := range cookies { + e, err := j.newEntry(cookie, now, defPath, host) + if err != nil { + continue + } + e.CanonicalHost = host + id := e.id() + if submap == nil { + submap = make(map[string]entry) + j.entries[key] = submap + } + if old, ok := submap[id]; ok { + e.Creation = old.Creation + } else { + e.Creation = now + } + e.Updated = now + e.LastAccess = now + submap[id] = e + } +} + +// canonicalHost strips port from host if present and returns the canonicalized +// host name. +func canonicalHost(host string) (string, error) { + var err error + host = strings.ToLower(host) + if hasPort(host) { + host, _, err = net.SplitHostPort(host) + if err != nil { + return "", err + } + } + if strings.HasSuffix(host, ".") { + // Strip trailing dot from fully qualified domain names. + host = host[:len(host)-1] + } + return toASCII(host) +} + +// hasPort reports whether host contains a port number. host may be a host +// name, an IPv4 or an IPv6 address. +func hasPort(host string) bool { + colons := strings.Count(host, ":") + if colons == 0 { + return false + } + if colons == 1 { + return true + } + return host[0] == '[' && strings.Contains(host, "]:") +} + +// jarKey returns the key to use for a jar. +func jarKey(host string, psl PublicSuffixList) string { + if isIP(host) { + return host + } + + var i int + if psl == nil { + i = strings.LastIndex(host, ".") + if i == -1 { + return host + } + } else { + suffix := psl.PublicSuffix(host) + if suffix == host { + return host + } + i = len(host) - len(suffix) + if i <= 0 || host[i-1] != '.' { + // The provided public suffix list psl is broken. + // Storing cookies under host is a safe stopgap. + return host + } + } + prevDot := strings.LastIndex(host[:i-1], ".") + return host[prevDot+1:] +} + +// isIP reports whether host is an IP address. +func isIP(host string) bool { + return net.ParseIP(host) != nil +} + +// defaultPath returns the directory part of an URL's path according to +// RFC 6265 section 5.1.4. +func defaultPath(path string) string { + if len(path) == 0 || path[0] != '/' { + return "/" // Path is empty or malformed. + } + + i := strings.LastIndex(path, "/") // Path starts with "/", so i != -1. + if i == 0 { + return "/" // Path has the form "/abc". + } + return path[:i] // Path is either of form "/abc/xyz" or "/abc/xyz/". +} + +// newEntry creates an entry from a http.Cookie c. now is the current +// time and is compared to c.Expires to determine deletion of c. defPath +// and host are the default-path and the canonical host name of the URL +// c was received from. +// +// The returned entry should be removed if its expiry time is in the +// past. In this case, e may be incomplete, but it will be valid to call +// e.id (which depends on e's Name, Domain and Path). +// +// A malformed c.Domain will result in an error. +func (j *Jar) newEntry(c *http.Cookie, now time.Time, defPath, host string) (e entry, err error) { + e.Name = c.Name + if c.Path == "" || c.Path[0] != '/' { + e.Path = defPath + } else { + e.Path = c.Path + } + + e.Domain, e.HostOnly, err = j.domainAndType(host, c.Domain) + if err != nil { + return e, err + } + // MaxAge takes precedence over Expires. + if c.MaxAge != 0 { + e.Persistent = true + e.Expires = now.Add(time.Duration(c.MaxAge) * time.Second) + if c.MaxAge < 0 { + return e, nil + } + } else if c.Expires.IsZero() { + e.Expires = endOfTime + } else { + e.Persistent = true + e.Expires = c.Expires + if !c.Expires.After(now) { + return e, nil + } + } + + e.Value = c.Value + e.Secure = c.Secure + e.HttpOnly = c.HttpOnly + + return e, nil +} + +var ( + errIllegalDomain = errors.New("cookiejar: illegal cookie domain attribute") + errMalformedDomain = errors.New("cookiejar: malformed cookie domain attribute") + errNoHostname = errors.New("cookiejar: no host name available (IP only)") +) + +// endOfTime is the time when session (non-persistent) cookies expire. +// This instant is representable in most date/time formats (not just +// Go's time.Time) and should be far enough in the future. +var endOfTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + +// domainAndType determines the cookie's domain and hostOnly attribute. +func (j *Jar) domainAndType(host, domain string) (string, bool, error) { + if domain == "" { + // No domain attribute in the SetCookie header indicates a + // host cookie. + return host, true, nil + } + + if isIP(host) { + // According to RFC 6265 domain-matching includes not being + // an IP address. + // TODO: This might be relaxed as in common browsers. + return "", false, errNoHostname + } + + // From here on: If the cookie is valid, it is a domain cookie (with + // the one exception of a public suffix below). + // See RFC 6265 section 5.2.3. + if domain[0] == '.' { + domain = domain[1:] + } + + if len(domain) == 0 || domain[0] == '.' { + // Received either "Domain=." or "Domain=..some.thing", + // both are illegal. + return "", false, errMalformedDomain + } + domain = strings.ToLower(domain) + + if domain[len(domain)-1] == '.' { + // We received stuff like "Domain=www.example.com.". + // Browsers do handle such stuff (actually differently) but + // RFC 6265 seems to be clear here (e.g. section 4.1.2.3) in + // requiring a reject. 4.1.2.3 is not normative, but + // "Domain Matching" (5.1.3) and "Canonicalized Host Names" + // (5.1.2) are. + return "", false, errMalformedDomain + } + + // See RFC 6265 section 5.3 #5. + if j.psList != nil { + if ps := j.psList.PublicSuffix(domain); ps != "" && !hasDotSuffix(domain, ps) { + if host == domain { + // This is the one exception in which a cookie + // with a domain attribute is a host cookie. + return host, true, nil + } + return "", false, errIllegalDomain + } + } + + // The domain must domain-match host: www.mycompany.com cannot + // set cookies for .ourcompetitors.com. + if host != domain && !hasDotSuffix(host, domain) { + return "", false, errIllegalDomain + } + + return domain, false, nil +} + +// DefaultCookieFile returns the default cookie file to use +// for persisting cookie data. +// The following names will be used in decending order of preference: +// - the value of the $GOCOOKIES environment variable. +// - $HOME/.go-cookies +func DefaultCookieFile() string { + if f := os.Getenv("GOCOOKIES"); f != "" { + return f + } + return filepath.Join(homeDir(), ".go-cookies") +} diff --git a/backend_v1/providers/req/cookiejar/punycode.go b/backend_v1/providers/req/cookiejar/punycode.go new file mode 100644 index 0000000..ea7ceb5 --- /dev/null +++ b/backend_v1/providers/req/cookiejar/punycode.go @@ -0,0 +1,159 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cookiejar + +// This file implements the Punycode algorithm from RFC 3492. + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// These parameter values are specified in section 5. +// +// All computation is done with int32s, so that overflow behavior is identical +// regardless of whether int is 32-bit or 64-bit. +const ( + base int32 = 36 + damp int32 = 700 + initialBias int32 = 72 + initialN int32 = 128 + skew int32 = 38 + tmax int32 = 26 + tmin int32 = 1 +) + +// encode encodes a string as specified in section 6.3 and prepends prefix to +// the result. +// +// The "while h < length(input)" line in the specification becomes "for +// remaining != 0" in the Go code, because len(s) in Go is in bytes, not runes. +func encode(prefix, s string) (string, error) { + output := make([]byte, len(prefix), len(prefix)+1+2*len(s)) + copy(output, prefix) + delta, n, bias := int32(0), initialN, initialBias + b, remaining := int32(0), int32(0) + for _, r := range s { + if r < 0x80 { + b++ + output = append(output, byte(r)) + } else { + remaining++ + } + } + h := b + if b > 0 { + output = append(output, '-') + } + for remaining != 0 { + m := int32(0x7fffffff) + for _, r := range s { + if m > r && r >= n { + m = r + } + } + delta += (m - n) * (h + 1) + if delta < 0 { + return "", fmt.Errorf("cookiejar: invalid label %q", s) + } + n = m + for _, r := range s { + if r < n { + delta++ + if delta < 0 { + return "", fmt.Errorf("cookiejar: invalid label %q", s) + } + continue + } + if r > n { + continue + } + q := delta + for k := base; ; k += base { + t := k - bias + if t < tmin { + t = tmin + } else if t > tmax { + t = tmax + } + if q < t { + break + } + output = append(output, encodeDigit(t+(q-t)%(base-t))) + q = (q - t) / (base - t) + } + output = append(output, encodeDigit(q)) + bias = adapt(delta, h+1, h == b) + delta = 0 + h++ + remaining-- + } + delta++ + n++ + } + return string(output), nil +} + +func encodeDigit(digit int32) byte { + switch { + case 0 <= digit && digit < 26: + return byte(digit + 'a') + case 26 <= digit && digit < 36: + return byte(digit + ('0' - 26)) + } + panic("cookiejar: internal error in punycode encoding") +} + +// adapt is the bias adaptation function specified in section 6.1. +func adapt(delta, numPoints int32, firstTime bool) int32 { + if firstTime { + delta /= damp + } else { + delta /= 2 + } + delta += delta / numPoints + k := int32(0) + for delta > ((base-tmin)*tmax)/2 { + delta /= base - tmin + k += base + } + return k + (base-tmin+1)*delta/(delta+skew) +} + +// Strictly speaking, the remaining code below deals with IDNA (RFC 5890 and +// friends) and not Punycode (RFC 3492) per se. + +// acePrefix is the ASCII Compatible Encoding prefix. +const acePrefix = "xn--" + +// toASCII converts a domain or domain label to its ASCII form. For example, +// toASCII("bücher.example.com") is "xn--bcher-kva.example.com", and +// toASCII("golang") is "golang". +func toASCII(s string) (string, error) { + if ascii(s) { + return s, nil + } + labels := strings.Split(s, ".") + for i, label := range labels { + if !ascii(label) { + a, err := encode(acePrefix, label) + if err != nil { + return "", err + } + labels[i] = a + } + } + return strings.Join(labels, "."), nil +} + +func ascii(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + return false + } + } + return true +} diff --git a/backend_v1/providers/req/cookiejar/serialize.go b/backend_v1/providers/req/cookiejar/serialize.go new file mode 100644 index 0000000..4264e19 --- /dev/null +++ b/backend_v1/providers/req/cookiejar/serialize.go @@ -0,0 +1,188 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cookiejar + +import ( + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/retry.v1" + + filelock "github.com/juju/go4/lock" + "github.com/pkg/errors" +) + +// Save saves the cookies to the persistent cookie file. +// Before the file is written, it reads any cookies that +// have been stored from it and merges them into j. +func (j *Jar) Save() error { + if j.filename == "" { + return nil + } + return j.save(time.Now()) +} + +// MarshalJSON implements json.Marshaler by encoding all persistent cookies +// currently in the jar. +func (j *Jar) MarshalJSON() ([]byte, error) { + j.mu.Lock() + defer j.mu.Unlock() + // Marshaling entries can never fail. + data, _ := json.Marshal(j.allPersistentEntries()) + return data, nil +} + +// save is like Save but takes the current time as a parameter. +func (j *Jar) save(now time.Time) error { + locked, err := lockFile(lockFileName(j.filename)) + if err != nil { + return err + } + defer locked.Close() + f, err := os.OpenFile(j.filename, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return err + } + defer f.Close() + // TODO optimization: if the file hasn't changed since we + // loaded it, don't bother with the merge step. + + j.mu.Lock() + defer j.mu.Unlock() + if err := j.mergeFrom(f); err != nil { + // The cookie file is probably corrupt. + log.Printf("cannot read cookie file to merge it; ignoring it: %v", err) + } + j.deleteExpired(now) + if err := f.Truncate(0); err != nil { + return errors.Wrap(err, "cannot truncate file") + } + if _, err := f.Seek(0, 0); err != nil { + return err + } + return j.writeTo(f) +} + +// load loads the cookies from j.filename. If the file does not exist, +// no error will be returned and no cookies will be loaded. +func (j *Jar) load() error { + if _, err := os.Stat(filepath.Dir(j.filename)); os.IsNotExist(err) { + // The directory that we'll store the cookie jar + // in doesn't exist, so don't bother trying + // to acquire the lock. + return nil + } + locked, err := lockFile(lockFileName(j.filename)) + if err != nil { + return err + } + defer locked.Close() + f, err := os.Open(j.filename) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + if err := j.mergeFrom(f); err != nil { + return err + } + return nil +} + +// mergeFrom reads all the cookies from r and stores them in the Jar. +func (j *Jar) mergeFrom(r io.Reader) error { + decoder := json.NewDecoder(r) + // Cope with old cookiejar format by just discarding + // cookies, but still return an error if it's invalid JSON. + var data json.RawMessage + if err := decoder.Decode(&data); err != nil { + if err == io.EOF { + // Empty file. + return nil + } + return err + } + var entries []entry + if err := json.Unmarshal(data, &entries); err != nil { + log.Printf("warning: discarding cookies in invalid format (error: %v)", err) + return nil + } + j.merge(entries) + return nil +} + +// writeTo writes all the cookies in the jar to w +// as a JSON array. +func (j *Jar) writeTo(w io.Writer) error { + encoder := json.NewEncoder(w) + entries := j.allPersistentEntries() + if err := encoder.Encode(entries); err != nil { + return err + } + return nil +} + +// allPersistentEntries returns all the entries in the jar, sorted by primarly by canonical host +// name and secondarily by path length. +func (j *Jar) allPersistentEntries() []entry { + var entries []entry + for _, submap := range j.entries { + for _, e := range submap { + if e.Persistent { + entries = append(entries, e) + } + } + } + sort.Sort(byCanonicalHost{entries}) + return entries +} + +func (j *Jar) KVData() map[string]string { + pairs := make(map[string]string) + + entries := j.allPersistentEntries() + if len(entries) == 0 { + return pairs + } + + for _, entry := range entries { + pairs[strings.ToLower(entry.Name)] = entry.Value + } + + return pairs +} + +// lockFileName returns the name of the lock file associated with +// the given path. +func lockFileName(path string) string { + return path + ".lock" +} + +var attempt = retry.LimitTime(3*time.Second, retry.Exponential{ + Initial: 100 * time.Microsecond, + Factor: 1.5, + MaxDelay: 100 * time.Millisecond, +}) + +func lockFile(path string) (io.Closer, error) { + for a := retry.Start(attempt, nil); a.Next(); { + locker, err := filelock.Lock(path) + if err == nil { + return locker, nil + } + if !a.More() { + return nil, errors.Wrap(err, "file locked for too long; giving up") + } + } + panic("unreachable") +} diff --git a/backend_v1/providers/wechat/certs/apiclient_cert.p12 b/backend_v1/providers/wechat/certs/apiclient_cert.p12 new file mode 100644 index 0000000000000000000000000000000000000000..86eadfb8615e54db4a8ad5b0ba208494416358dd GIT binary patch literal 2782 zcmY+^c{CJ`76vL>?2PBhQ&ocG@Ey+7``=YH=!_s>GPdLs|2-0KQvf^!R@_iY1*hy!PPaTF98@&Q2AScv|_qsQ4y;P2O zsvk{vOY6f68vzI64}c4=c%B~&KKe))uqIB`)#+&JhEMhTvjm%UE4q^~?e;nGf@Z(A zja!bqn9iaatE;d{Yp$QrLIUt?YD2qqinl06RbWRI>}a+Ag?9lRJ@jX%MR#(!>P^b% ziE;9jWcO{sb5mQYIB2myEP|`!?W(7ectuU%nko+*ojG0;QPvz0f3!A^1TU@nx|Zb- zr|HMJ>{V}W6}Mnn7t9kh{0r@vEIq6<5|u6LS8{n$$0i~;lL<(v2FWd2Xt>PU&ocp{ zLl*PGw~jhIyX$n;=#8L0zj=mt0$9gmyDXm}Z#^j3^h&{tNSb-VjE zj^PouHwaWR|CQl2${C#gU_;kN=~g4z=lp?s1@`?H zlPWgy&PSs=*@KDaE{&S?3d@vqm2hct_z-k&Yv-E{{u1eLq(qrF9&kRK06U3;~&Xb26xO%~;(~tJ!D7)#U*d0&h*V5bJi3TVJrpU`hm}4H#!xkYA2&KtTa5@nlD0{<~G7S&W zFHT!3{iPLdGZN|n`7q4Q@no(uHb);lGWnKZormmVHl&U50r?HjLS#h(*zPeW8nmX! zuirbK3)IdZTCL(nsH|U{=KfX-X6dlM+gg~X+$Kyhs?5B}cqBJdy?QkKL_%16U|VqKql#^2oMM>h8KeHqV+hiI zS8jkB1MM2j+KDoW-*gxg830!Gu>OrqSc=tU z6fMDOqqj60Pwv@i`rXF_9PIN)Tm=s+iNrrJSkSMMv4KqZFYKjegt>Q>WM=WOO zrxJf*c^_O9s;E-Tu!1F>;LgD{_X^>p&#KFpPDu>>ANWp5bOYhR&Zn{cY2AdJ`459I zARQ48T0ezy`TumJ|69iiiLaVYc{sMebp)O2=$NiV!;UDGVHS}F%R!9Pb1#v!vEP19 zH-|cT5h1HOK1)(;wIfNPvVH0^qgnp9^hH&w|I8lck?R4+mF*m>PXa0WiwJ+4Y~ml< zF6907s;+MV8C4-az_}WKNB%Gg&Vrc*eOYbhch@5YyB7xM zso6N|?@+>g)Sa3)aj9x@0JidPeezY$s$O&B0_2K+&rQJN)P3B-y)wN4KU6srjhVmV z9Db8b601=x-~$E@Qgwi@teHaTa+Gvf>A)fbed@&Qa(HaASs>-c%-)Y}c`te0$8B~o z{mR}&i;umMb=&g*-**+}JjG?#(t8rS{;bA&FiTba?Zyw~3as-SugRx8+;TvX`#WHLj~Ws=PH* zJE@Z&!bN^1yZ4j%j0pwTk;_U!En+9s_D!D*uB4u;QiVM@Vyi(~ctdT_2OaZJFX_T7 z$vvCz1|ME)f3xm3+Umm1aChQv{m4zP=KMW50h?zZ7z}CM6`?j(<%yN%PtR^MVT0{L zxJY@{Qy70T^g?D4e*RaRVGx59a!s$LTK)TXdPDW4_B%36{6XKwC%;o|FLgZMzhKbj>s@{;O=EH{rZd7VF!PvYIGlrJ5=x|sK-7S zl63g=ZpEF6Amn6Xs>o}+5nn>gE6C;jdE3|^M6o3oo7fPRfEnM=k~ZC5!pX%G%O4I9 zTGVldzZwx5X0-Bo;|r(~8-Wy-pK*H|`O|;$```jD{@`LPF}K|FOQYG=e6Ow7;BW6N zAFKz^01&%IDjq}w<;2TF#SW~! V!|S6fUv-0^fHoCWk#XJOzX0`lCa3@a literal 0 HcmV?d00001 diff --git a/backend_v1/providers/wechat/certs/apiclient_cert.pem b/backend_v1/providers/wechat/certs/apiclient_cert.pem new file mode 100644 index 0000000..e4479cd --- /dev/null +++ b/backend_v1/providers/wechat/certs/apiclient_cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIENDCCAxygAwIBAgIURWPsWEo1vIT7J6pBAMk0yakdWcowDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjUwNDE0MTIwMTI2WhcNMzAwNDEzMTIwMTI2WjCBjTETMBEGA1UEAwwK +MTcwMjY0NDk0NzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTkwNwYDVQQL +DDDkvbPoioPvvIjljJfkuqzvvInkvIHkuJrnrqHnkIblkqjor6LmnInpmZDlhazl +j7gxCzAJBgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAL4aNZ3BGiuBBfBnyi5l5bxSxSCOJJQ8oskcY5lA +RJrT3GbOb4NVlY0I8Qcm/PVsOZI1dWxBRZET/7IzBJ9759qrR3gFmLDW54VtHKPh +XD/HsHa9jSLzKjRXJOdZ0LpBlFz5X51u48kzU6T5B/bKD41mHPde5Na9A6xwBz35 +/dqPx+FclCVGY1vLvfrDSIO70RAW8+eRWzXT+VZHAgK/MRsQyrsPZJJdL+Vz+pLz +h4dhgKcfxvc0Y0K3uJ9Jc8l6wZP/6nAEqY95/pOUrSmOCqqIqW0/Tidh4/tSPPmv +y/8I95tQboi6G3cSMfziLhQKlwF0j+YPaskicn+OKIKM/u0CAwEAAaOBuTCBtjAJ +BgNVHRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGE +aHR0cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0 +MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFC +NjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEB +CwUAA4IBAQC1sIAaLiXzhLJj0XzTFlCiJ3KPggLA4PnbNvzj6sma0ojx8mOHgfHb +hR216vGY0Ll9ZpbAYR9GdEuUWVawZ38Z4GJVFAOCr1pp6DqeM3A/dTk+V4vJawZz +85AtfL1/heU1xsW0AbyPrfDiMHMieHEDNvRvHjQmjZ42aRbHDdRzDH0TIt0paRPB ++ubwCmr947oMe01PWWvF8g032d6NxN4CTPuBuWnJG9OQOm2KQDb4z5GftiJnFbay +KB3WycuqEFbHXVFgn7jrc9+uX0oRE7+iIfGqpcfJrKD93lP2r9AZ6Oxhk3TaNFSQ +u+/uR1Lg1b6vIJqI8otjDH9j5QVLAj5k +-----END CERTIFICATE----- diff --git a/backend_v1/providers/wechat/certs/apiclient_key.pem b/backend_v1/providers/wechat/certs/apiclient_key.pem new file mode 100644 index 0000000..1a8ad54 --- /dev/null +++ b/backend_v1/providers/wechat/certs/apiclient_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+GjWdwRorgQXw +Z8ouZeW8UsUgjiSUPKLJHGOZQESa09xmzm+DVZWNCPEHJvz1bDmSNXVsQUWRE/+y +MwSfe+faq0d4BZiw1ueFbRyj4Vw/x7B2vY0i8yo0VyTnWdC6QZRc+V+dbuPJM1Ok ++Qf2yg+NZhz3XuTWvQOscAc9+f3aj8fhXJQlRmNby736w0iDu9EQFvPnkVs10/lW +RwICvzEbEMq7D2SSXS/lc/qS84eHYYCnH8b3NGNCt7ifSXPJesGT/+pwBKmPef6T +lK0pjgqqiKltP04nYeP7Ujz5r8v/CPebUG6Iuht3EjH84i4UCpcBdI/mD2rJInJ/ +jiiCjP7tAgMBAAECggEAVYVe94BGsKmTrWpT13m513X4/sNTi2iX5xehavExq+GB +trJKEnBvHgqWvBv7EsHESJVKJRBcJn8zucwf2UuZq5MATOtfnLahYzIJ/2PD52GD +bnepxb5VD0Tg8j9CmngkMYtyS1X2na48g+wQfCK8ymTUxSholH5l565iY6xSWn8r +SD/u/EBLv69i40uocG1hUUicrJZ1wc5T0ct3GpfiA1BfH462/dp6mROONdpwM8IT +ltRH4wjIc2nPgE7eNbXlHg+KkqyNNLA+BeN3yn001QwvP6Q0panuCTsVVlvEuGAY +RwXbu/0fHFbppIpgfr7AFGRWKTF66Peq3ozsG9jNgQKBgQDviSJxN2Mpdln4i5F3 +74s8FMtZ5bY63RHHcvJ5/D9G1iDNHFgLJsgdrbAhLqBbqg73EsIT8TsPlAqKPKS8 +EGKBg75MsMSYu7EmzIURV3Gy+Pou9jOkTUfQfblkiV+uJjWQPlBlfksL1bQnfSvZ +Pk1DCwGMb5DMDazAQLP9/wtLYQKBgQDLKz9YHF+wFsnfUjBQngDLCTkxrfxp8y84 +s/z5IRZIEdfxmnaEeWJXYa0oeQumNLSVHrryvHm3vkBgKexN49TWUGIM3q54gi/R +FPXXJKarDEI7C86Th3g+3FPEez5v+CEncmlB9X3kBT0ZFROWD3HHaz2DUKPVmJe1 +eUOtAN0LDQKBgCoulx8i5taFXgCz61EYoQdajhjtp/KjvZ7G8kZjEm2SBcK5DBQi +pzj6vjqJsHmT8AC4j+7dG055/oUresMXi5FNNvTgaC6RVvgDKifMo1wmFkCw4JU9 +erkPetdmja/oUKRvJM9Kt0KFRq1xkIg4PXjh9krZ1sDoY5STkF7ZTA7hAoGAQhPv +xzV7Pac7wwFVK3MoKOD4FBtVRBRO4G9RsKk9OPVsuWyWbWGZRXhEPCyaSFVOAk37 +WaVJJSSghWY9L9wQxh9gtHTcY99bs/HQP0fxWSJkjBW7+ymNR0ybhgTbeslF5zGD +4Gr6peW6SGUdeKnPRJ+xYvsgPgEiHmixRRxJyCUCgYEAoguVZdpDaRDZGGrTghwj +F4kMIyEczFeBZtK2JEGSLA6j8uj+oBZ26c6K4sh/Btc0l6IkiXijXbTaH87s52xZ +im8aIZZ9jDKUFxtjVUL0l9fjRsCLAvaBbWw3z4EdtOGuYlnhNCheeSd+/Lzqrb1q +pnTiwBHnQCMFFL/rNcz/Mmk= +-----END PRIVATE KEY----- diff --git a/backend_v1/providers/wechat/config.go b/backend_v1/providers/wechat/config.go new file mode 100644 index 0000000..01d3043 --- /dev/null +++ b/backend_v1/providers/wechat/config.go @@ -0,0 +1,59 @@ +package wechat + +import ( + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +const DefaultPrefix = "WeChat" + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{ + opt.Prefix(DefaultPrefix), + }, + } +} + +func Provide(opts ...opt.Option) error { + o := opt.New(opts...) + var config Config + if err := o.UnmarshalConfig(&config); err != nil { + return err + } + + return container.Container.Provide(func() (*Config, *Client, error) { + httpClient := DefaultClient + if config.DevMode { + httpClient = httpClient.DevMode() + } + return &config, New( + WithAppID(config.AppID), + WithAppSecret(config.AppSecret), + WithAESKey(config.EncodingAESKey), + WithToken(config.Token), + WithClient(httpClient), + ), nil + }, o.DiOptions()...) +} + +type Config struct { + AppID string + AppSecret string + Token string + EncodingAESKey string + DevMode bool + Pay *Pay +} + +type Pay struct { + MchID string + SerialNo string + MechName string + NotifyURL string + ApiV3Key string + PrivateKey string + PublicKeyID string + PublicKey string +} diff --git a/backend_v1/providers/wechat/errors.go b/backend_v1/providers/wechat/errors.go new file mode 100644 index 0000000..e8da727 --- /dev/null +++ b/backend_v1/providers/wechat/errors.go @@ -0,0 +1,59 @@ +package wechat + +import "github.com/pkg/errors" + +// -1 系统繁忙,此时请开发者稍候再试 +// 0 请求成功 +// 40001 AppSecret错误或者AppSecret不属于这个公众号,请开发者确认AppSecret的正确性 +// 40002 请确保grant_type字段值为client_credential +// 40164 调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置。 +// 40243 AppSecret已被冻结,请登录MP解冻后再次调用。 +// 89503 此IP调用需要管理员确认,请联系管理员 +// 89501 此IP正在等待管理员确认,请联系管理员 +// 89506 24小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用 +// 89507 1小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用 +// 10003 redirect_uri域名与后台配置不一致 +// 10004 此公众号被封禁 +// 10005 此公众号并没有这些scope的权限 +// 10006 必须关注此测试号 +// 10009 操作太频繁了,请稍后重试 +// 10010 scope不能为空 +// 10011 redirect_uri不能为空 +// 10012 appid不能为空 +// 10013 state不能为空 +// 10015 公众号未授权第三方平台,请检查授权状态 +// 10016 不支持微信开放平台的Appid,请使用公众号Appid +func translateError(errCode int, msg string) error { + if errCode == 0 { + return nil + } + + errs := map[int]error{ + 0: nil, + -1: errors.New("系统繁忙,此时请开发者稍候再试"), + 40001: errors.New("AppSecret错误或者AppSecret不属于这个公众号,请开发者确认AppSecret的正确性"), + 40002: errors.New("请确保grant_type字段值为client_credential"), + 40164: errors.New("调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置"), + 40243: errors.New("AppSecret已被冻结,请登录MP解冻后再次调用"), + 89503: errors.New("此IP调用需要管理员确认,请联系管理员"), + 89501: errors.New("此IP正在等待管理员确认,请联系管理员"), + 89506: errors.New("24小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用"), + 89507: errors.New("1小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用"), + 10003: errors.New("redirect_uri域名与后台配置不一致"), + 10004: errors.New("此公众号被封禁"), + 10005: errors.New("此公众号并没有这些scope的权限"), + 10006: errors.New("必须关注此测试号"), + 10009: errors.New("操作太频繁了,请稍后重试"), + 10010: errors.New("scope不能为空"), + 10011: errors.New("redirect_uri不能为空"), + 10012: errors.New("appid不能为空"), + 10013: errors.New("state不能为空"), + 10015: errors.New("公众号未授权第三方平台,请检查授权状态"), + 10016: errors.New("不支持微信开放平台的Appid,请使用公众号Appid"), + } + + if err, ok := errs[errCode]; ok { + return err + } + return errors.New(msg) +} diff --git a/backend_v1/providers/wechat/funcs.go b/backend_v1/providers/wechat/funcs.go new file mode 100644 index 0000000..c5c3de2 --- /dev/null +++ b/backend_v1/providers/wechat/funcs.go @@ -0,0 +1,24 @@ +package wechat + +import ( + "crypto/sha1" + "encoding/hex" + "math/rand" +) + +// RandomString generate random size string +func randomString(size int) string { + // generate size string [0-9a-zA-Z] + const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + b := make([]byte, size) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} + +func hashSha1(input string) string { + h := sha1.New() + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/backend_v1/providers/wechat/options.go b/backend_v1/providers/wechat/options.go new file mode 100644 index 0000000..68b0e01 --- /dev/null +++ b/backend_v1/providers/wechat/options.go @@ -0,0 +1,76 @@ +package wechat + +import ( + "net/url" + + "github.com/imroc/req/v3" +) + +type Options func(*Client) + +func WithAppID(appID string) Options { + return func(we *Client) { + we.appID = appID + } +} + +// WithAppSecret sets the app secret +func WithAppSecret(appSecret string) Options { + return func(we *Client) { + we.appSecret = appSecret + } +} + +// WithToken sets the token +func WithToken(token string) Options { + return func(we *Client) { + we.token = token + } +} + +// WithAESKey sets the AES key +func WithAESKey(aesKey string) Options { + return func(we *Client) { + we.aesKey = aesKey + } +} + +// WithClient sets the http client +func WithClient(client *req.Client) Options { + return func(we *Client) { + we.client = client + } +} + +type ScopeAuthorizeURLOptions func(url.Values) + +func ScopeAuthorizeURLWithScope(scope AuthScope) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("scope", scope.String()) + } +} + +func ScopeAuthorizeURLWithRedirectURI(uri string) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("redirect_uri", uri) + } +} + +func ScopeAuthorizeURLWithState(state string) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("state", state) + } +} + +func ScopeAuthorizeURLWithForcePopup() ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("forcePopup", "true") + } +} + +func WithVerifySiteKeyPair(key, value string) Options { + return func(we *Client) { + we.verifyKey = key + we.verifyValue = value + } +} diff --git a/backend_v1/providers/wechat/response.go b/backend_v1/providers/wechat/response.go new file mode 100644 index 0000000..225b37d --- /dev/null +++ b/backend_v1/providers/wechat/response.go @@ -0,0 +1,17 @@ +package wechat + +type ErrorResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +func (r *ErrorResponse) Error() error { + return translateError(r.ErrCode, r.ErrMsg) +} + +type AccessTokenResponse struct { + ErrorResponse + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` // seconds +} diff --git a/backend_v1/providers/wechat/wechat.go b/backend_v1/providers/wechat/wechat.go new file mode 100644 index 0000000..95d039e --- /dev/null +++ b/backend_v1/providers/wechat/wechat.go @@ -0,0 +1,339 @@ +package wechat + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "quyun/v2/pkg/oauth" + + "github.com/imroc/req/v3" + "github.com/pkg/errors" +) + +const BaseURL = "https://api.weixin.qq.com/" + +var DefaultClient = req. + NewClient(). + SetBaseURL(BaseURL). + SetCommonHeader("Content-Type", "application/json") + +const ( + ScopeBase = "snsapi_base" + ScopeUserInfo = "snsapi_userinfo" +) + +type AuthScope string + +func (s AuthScope) String() string { + return string(s) +} + +type Client struct { + client *req.Client + + appID string + appSecret string + token string + aesKey string + + verifyKey string + verifyValue string +} + +func New(options ...Options) *Client { + we := &Client{ + client: DefaultClient, + } + + for _, opt := range options { + opt(we) + } + + return we +} + +func (we *Client) VerifySite(key string) (string, error) { + if key == we.verifyKey { + return we.verifyValue, nil + } + return "", errors.New("verify failed") +} + +func (we *Client) Verify(signature, timestamp, nonce string) error { + params := []string{signature, timestamp, nonce, we.token} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + hashStr := hex.EncodeToString(hash[:]) + + if hashStr == signature { + return errors.New("Signature verification failed") + } + + return nil +} + +func (we *Client) wrapParams(params map[string]string) map[string]string { + if params == nil { + params = make(map[string]string) + } + + params["appid"] = we.appID + params["secret"] = we.appSecret + + return params +} + +// RefreshAccessToken +func (we *Client) RefreshAccessToken(refreshToken string) (*AccessTokenResponse, error) { + params := we.wrapParams(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + }) + + var data AccessTokenResponse + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/oauth2/refresh_token") + if err != nil { + return nil, errors.Wrap(err, "call /sns/oauth2/refresh_token failed") + } + + if data.ErrCode != 0 { + return nil, data.Error() + } + + return &data, nil +} + +func (we *Client) GetAccessToken() (*AccessTokenResponse, error) { + params := map[string]string{ + "grant_type": "client_credential", + } + + var data ErrorResponse + resp, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/cgi-bin/token") + if err != nil { + return nil, errors.Wrap(err, "call /cgi-bin/token failed") + } + + if data.ErrCode != 0 { + return nil, data.Error() + } + + var token AccessTokenResponse + if err := resp.Unmarshal(&token); err != nil { + return nil, errors.Wrap(err, "parse response failed") + } + + return &token, nil +} + +// ScopeAuthorizeURL +func (we *Client) ScopeAuthorizeURL(opts ...ScopeAuthorizeURLOptions) (*url.URL, error) { + params := url.Values{} + params.Add("appid", we.appID) + params.Add("response_type", "code") + + for _, opt := range opts { + opt(params) + } + + if params.Get("scope") == "" { + params.Add("scope", ScopeBase) + } + + u, err := url.Parse("https://open.weixin.qq.com/connect/oauth2/authorize") + if err != nil { + return nil, errors.Wrap(err, "parse url failed") + } + + u.Fragment = "wechat_redirect" + u.RawQuery = url.Values(params).Encode() + + return u, nil +} + +var _ oauth.OAuthInfo = (*AuthorizeAccessToken)(nil) + +type AuthorizeAccessToken struct { + ErrorResponse + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + IsSnapshotuser int64 `json:"is_snapshotuser,omitempty"` + Openid string `json:"openid,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + Unionid string `json:"unionid,omitempty"` +} + +// GetAccessToken implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetAccessToken() string { + return a.AccessToken +} + +// GetExpiredAt implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetExpiredAt() time.Time { + return time.Now().Add(time.Duration(a.ExpiresIn) * time.Second) +} + +// GetOpenID implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetOpenID() string { + return a.Openid +} + +// GetRefreshToken implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetRefreshToken() string { + return a.RefreshToken +} + +// GetUnionID implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetUnionID() string { + return a.Unionid +} + +type StableAccessToken struct { + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` +} + +func (we *Client) GetStableAccessToken() (*StableAccessToken, error) { + params := we.wrapParams(map[string]string{ + "grant_type": "client_credential", + }) + + var data StableAccessToken + _, err := we.client.R().SetSuccessResult(&data).SetBodyJsonMarshal(params).Post("/cgi-bin/stable_token") + if err != nil { + return nil, errors.Wrap(err, "call /cgi-bin/stable_token failed") + } + + return &data, nil +} + +func (we *Client) AuthorizeCode2Token(code string) (*AuthorizeAccessToken, error) { + params := we.wrapParams(map[string]string{ + "code": code, + "grant_type": "authorization_code", + }) + + var data AuthorizeAccessToken + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/oauth2/access_token") + if err != nil { + return nil, errors.Wrap(err, "call /sns/oauth2/access_token failed") + } + + if err := data.Error(); err != nil { + return nil, err + } + + return &data, nil +} + +func (we *Client) AuthorizeRefreshAccessToken(accessToken string) (*AuthorizeAccessToken, error) { + params := we.wrapParams(map[string]string{ + "refresh_token": accessToken, + "grant_type": "refresh_token", + }) + + var data AuthorizeAccessToken + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/oauth2/refresh_token") + if err != nil { + return nil, errors.Wrap(err, "call /sns/oauth2/refresh_token failed") + } + + if err := data.Error(); err != nil { + return nil, err + } + + return &data, nil +} + +type AuthorizeUserInfo struct { + ErrorResponse + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + Headimgurl string `json:"headimgurl,omitempty"` + Nickname string `json:"nickname,omitempty"` + Openid string `json:"openid,omitempty"` + Privilege []string `json:"privilege,omitempty"` + Province string `json:"province,omitempty"` + Sex int64 `json:"sex,omitempty"` + Unionid string `json:"unionid,omitempty"` +} + +func (we *Client) AuthorizeUserInfo(accessToken, openID string) (*AuthorizeUserInfo, error) { + params := (map[string]string{ + "access_token": accessToken, + "openid": openID, + }) + + var data AuthorizeUserInfo + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/userinfo") + if err != nil { + return nil, errors.Wrap(err, "call /sns/userinfo failed") + } + + if err := data.Error(); err != nil { + return nil, err + } + + return &data, nil +} + +// GetJSTicket +func (we *Client) GetJSTicket(token string) (string, error) { + var data struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Ticket string `json:"ticket"` + ExpiresIn int `json:"expires_in"` + } + + params := map[string]string{ + "access_token": token, + "type": "jsapi", + } + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/cgi-bin/ticket/getticket") + if err != nil { + return "", errors.Wrap(err, "call /cgi-bin/ticket/getticket failed") + } + + if data.Errcode != 0 { + return "", errors.New("get wechat ticket failed: " + data.Errmsg) + } + + return data.Ticket, nil +} + +type JsSDK struct { + Debug bool `json:"debug"` + AppID string `json:"appId"` + Timestamp int64 `json:"timestamp"` + NonceStr string `json:"nonceStr"` + Signature string `json:"signature"` +} + +// GetJSTicket +func (we *Client) GetJsSDK(token, url string) (*JsSDK, error) { + sdk := &JsSDK{ + Debug: false, + AppID: we.appID, + Timestamp: time.Now().Unix(), + NonceStr: randomString(16), + Signature: "", + } + // get ticket + ticket, err := we.GetJSTicket(token) + if err != nil { + return nil, errors.Wrap(err, "get wechat ticket failed") + } + + input := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticket, sdk.NonceStr, sdk.Timestamp, url) + sdk.Signature = hashSha1(input) + + return sdk, nil +} diff --git a/backend_v1/providers/wechat/wechat_test.go b/backend_v1/providers/wechat/wechat_test.go new file mode 100644 index 0000000..c7f4f49 --- /dev/null +++ b/backend_v1/providers/wechat/wechat_test.go @@ -0,0 +1,107 @@ +package wechat + +import ( + "testing" + + log "github.com/sirupsen/logrus" + . "github.com/smartystreets/goconvey/convey" +) + +const ( + WechatAppID = "wx45745a8c51091ae0" + WechatAppSecret = "2ab33bc79d9b47efa4abef19d66e1977" + WechatToken = "W8Xhw5TivYBgY" + WechatAesKey = "F6AqCxAV4W1eCrY6llJ2zapphKK49CQN3RgtPDrjhnI" +) + +func init() { + log.SetLevel(log.DebugLevel) +} + +func getClient() *Client { + return New( + WithAppID(WechatAppID), + WithAppSecret(WechatAppSecret), + WithAESKey(WechatAesKey), + WithToken(WechatToken), + WithClient(DefaultClient.DevMode()), + ) +} + +func TestWechatClient_GetAccessToken(t *testing.T) { + Convey("Test GetAccessToken", t, func() { + token, err := getClient().GetAccessToken() + So(err, ShouldBeNil) + So(token.AccessToken, ShouldNotBeEmpty) + So(token.ExpiresIn, ShouldBeGreaterThan, 0) + + t.Log("Access Token:", token.AccessToken) + }) +} + +func TestClient_ScopeAuthorizeURL(t *testing.T) { + Convey("Test ScopeAuthorizeURL", t, func() { + url, err := getClient().ScopeAuthorizeURL( + ScopeAuthorizeURLWithScope(ScopeBase), + ScopeAuthorizeURLWithRedirectURI("https://qvyun.mp.jdwan.com/"), + ) + So(err, ShouldBeNil) + So(url, ShouldNotBeEmpty) + t.Log("URL:", url) + }) +} + +func TestClient_AuthorizeCode2Token(t *testing.T) { + code := "011W1sll2Xv4Ae4OjUnl2I7jvd2W1slX" + + Convey("Test AuthorizeCode2Token", t, func() { + token, err := getClient().AuthorizeCode2Token(code) + So(err, ShouldBeNil) + + t.Logf("token: %+v", token) + }) +} + +func TestClient_AuthorizeRefreshAccessToken(t *testing.T) { + token := "86_m_EAHq0RKlo6RzzGAsY8gVmiCqHqIiAJufxhm8mK8imyIW6yoE4NTcIr2vaukp7dexPWId0JWP1iZWYaLpXT_MJv1N7YQW8Qt3zOZDpJY90" + + Convey("Test AuthorizeCode2Token", t, func() { + token, err := getClient().AuthorizeRefreshAccessToken(token) + So(err, ShouldBeNil) + + t.Logf("token: %+v", token) + }) +} + +func TestClient_AuthorizeUserInfo(t *testing.T) { + token := "86_ZxJa8mIwbml5mDlHHbIUle_UKW8LA75nOuB0wqiome8AX5LlMWU8JwRKMZykdLEjDnKX8EJavz5GeQn3T1ot7TwpULp8imQvNIgFIjC4er8" + openID := "oMLa5tyJ2vRHa-HI4CMEkHztq3eU" + + Convey("Test AuthorizeUserInfo", t, func() { + user, err := getClient().AuthorizeUserInfo(token, openID) + So(err, ShouldBeNil) + + t.Logf("user: %+v", user) + }) +} + +func Test_GetJsTicket(t *testing.T) { + Convey("Test GetJsTicket", t, func() { + token := "91_0pKuAiBFquPdLakDyhYqOyNJkGLr7-Egx-IF4bRzw-2Lpm7wxgz6zVBNJ36FvMXmiu8bz9BTtspVICf1zDZ3XWuVLwTq6T3a6WG1k6NHv6E0PadT-G5x2Y85-xUECBcADATRQ" + ticket, err := getClient().GetJSTicket(token) + So(err, ShouldBeNil) + So(ticket, ShouldNotBeEmpty) + + t.Log("Js Ticket:", ticket) + }) +} + +func Test_GetStableToken(t *testing.T) { + Convey("Test_GetStableToken GetJsTicket", t, func() { + token, err := getClient().GetStableAccessToken() + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + t.Logf("Stable Token: %+v", token) + }) +} diff --git a/backend_v1/providers/wepay/config.go b/backend_v1/providers/wepay/config.go new file mode 100644 index 0000000..0be488c --- /dev/null +++ b/backend_v1/providers/wepay/config.go @@ -0,0 +1,58 @@ +package wepay + +import ( + "time" + + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +const DefaultPrefix = "WePay" + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{ + opt.Prefix(DefaultPrefix), + }, + } +} + +type PayNotify struct { + Mchid string `json:"mchid"` + Appid string `json:"appid"` + OutTradeNo string `json:"out_trade_no"` + TransactionID string `json:"transaction_id"` + TradeType string `json:"trade_type"` + TradeState string `json:"trade_state"` + TradeStateDesc string `json:"trade_state_desc"` + BankType string `json:"bank_type"` + Attach string `json:"attach"` + SuccessTime time.Time `json:"success_time"` + Payer struct { + Openid string `json:"openid"` + } `json:"payer"` + Amount struct { + Total int64 `json:"total"` + PayerTotal int64 `json:"payer_total"` + Currency string `json:"currency"` + PayerCurrency string `json:"payer_currency"` + } `json:"amount"` +} + +type RefundNotify struct { + Mchid string `json:"mchid"` + TransactionID string `json:"transaction_id"` + OutTradeNo string `json:"out_trade_no"` + RefundID string `json:"refund_id"` + OutRefundNo string `json:"out_refund_no"` + RefundStatus string `json:"refund_status"` + SuccessTime time.Time `json:"success_time"` + UserReceivedAccount string `json:"user_received_account"` + Amount struct { + Total int `json:"total"` + Refund int `json:"refund"` + PayerTotal int `json:"payer_total"` + PayerRefund int `json:"payer_refund"` + } `json:"amount"` +} diff --git a/backend_v1/providers/wepay/pay.go b/backend_v1/providers/wepay/pay.go new file mode 100644 index 0000000..396cbf3 --- /dev/null +++ b/backend_v1/providers/wepay/pay.go @@ -0,0 +1,330 @@ +package wepay + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "time" + + w "quyun/v2/providers/wechat" + + "github.com/go-pay/gopay" + "github.com/go-pay/gopay/wechat/v3" + "github.com/go-pay/util/js" + "github.com/gofiber/fiber/v3" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/opt" +) + +type Config struct{} + +func Provide(opts ...opt.Option) error { + o := opt.New(opts...) + var config Config + if err := o.UnmarshalConfig(&config); err != nil { + return err + } + return container.Container.Provide(func(wechatConfig *w.Config) (*Client, error) { + client, err := wechat.NewClientV3( + wechatConfig.Pay.MchID, + wechatConfig.Pay.SerialNo, + wechatConfig.Pay.ApiV3Key, + wechatConfig.Pay.PrivateKey, + ) + if err != nil { + return nil, err + } + + client.DebugSwitch = gopay.DebugOff + if wechatConfig.DevMode { + client.DebugSwitch = gopay.DebugOn + } + + err = client.AutoVerifySignByPublicKey([]byte(wechatConfig.Pay.PublicKey), wechatConfig.Pay.PublicKeyID) + if err != nil { + return nil, errors.Wrap(err, "AutoVerifySignByPublicKey") + } + + return &Client{ + payClient: client, + config: wechatConfig, + }, nil + }, o.DiOptions()...) +} + +type Client struct { + payClient *wechat.ClientV3 + config *w.Config +} + +func (c *Client) GetClient() *wechat.ClientV3 { + return c.payClient +} + +// WxPublicKeyMap +func (c *Client) WxPublicKeyMap() map[string]*rsa.PublicKey { + return c.payClient.WxPublicKeyMap() +} + +type PrepayData struct { + client *Client + + AppID string `json:"app_id"` + PrepayID string `json:"prepay_id"` +} + +// PaySignOfJSAPI +func (pay *PrepayData) PaySignOfJSAPI() (*wechat.JSAPIPayParams, error) { + return pay.client.payClient.PaySignOfJSAPI(pay.AppID, pay.PrepayID) +} + +func (c *Client) Refund(ctx context.Context, f func(*BodyMap)) (*wechat.RefundOrderResponse, error) { + bm := NewRefundBodyMap(c.config) + f(bm) + + resp, err := c.payClient.V3Refund(ctx, bm.bm) + if err != nil { + return nil, err + } + + if resp.Code != wechat.Success { + log.Errorf("WePay Refund error: %s", resp.Error) + return nil, errors.New(resp.Error) + } + + return resp.Response, nil +} + +func (c *Client) V3TransactionJsapi(ctx context.Context, f func(*BodyMap)) (*PrepayData, error) { + bm := NewBodyMap(c.config) + f(bm) + + resp, err := c.payClient.V3TransactionJsapi(ctx, bm.bm) + if err != nil { + return nil, err + } + + if resp.Code != wechat.Success { + b, _ := json.Marshal(resp) + log.Errorf("WePay V3TransactionJsapi error: %s", b) + return nil, errors.New(resp.Error) + } + + return &PrepayData{ + client: c, + + AppID: c.config.AppID, + PrepayID: resp.Response.PrepayId, + }, nil +} + +func (c *Client) ParseNotify( + ctx fiber.Ctx, + payCallback func(fiber.Ctx, *wechat.V3DecryptPayResult) error, + refundCallback func(fiber.Ctx, *wechat.V3DecryptRefundResult) error, +) error { + body := ctx.Body() + si := &wechat.SignInfo{ + HeaderTimestamp: ctx.Get(wechat.HeaderTimestamp), + HeaderNonce: ctx.Get(wechat.HeaderNonce), + HeaderSignature: ctx.Get(wechat.HeaderSignature), + HeaderSerial: ctx.Get(wechat.HeaderSerial), + SignBody: string(body), + } + + notifyReq := &wechat.V3NotifyReq{SignInfo: si} + if err := js.UnmarshalBytes(body, notifyReq); err != nil { + log.Errorf("json unmarshal error:%v", err) + return ctx.Status(http.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("json unmarshal error:%v", err)}) + } + + // 获取微信平台证书 + certMap := c.WxPublicKeyMap() + + // 验证异步通知的签名 + if err := notifyReq.VerifySignByPKMap(certMap); err != nil { + log.Errorf("verify sign error:%v", err) + return ctx.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid signature"}) + } + + // TRANSACTION.SUCCESS :支付成功通知 + // REFUND.SUCCESS:退款成功通知 + // REFUND.ABNORMAL:退款异常通知 + // REFUND.CLOSED:退款关闭通知 + switch notifyReq.EventType { + case "TRANSACTION.SUCCESS": + var notifyData wechat.V3DecryptPayResult + if err := notifyReq.DecryptCipherTextToStruct(c.config.Pay.ApiV3Key, ¬ifyData); err != nil { + return ctx.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cipher text"}) + } + log.Infof("Successfully decrypted cipher text for pay notify data: %+v", notifyData) + if err := payCallback(ctx, ¬ifyData); err != nil { + log.Errorf("payCallback error:%v", err) + return err + } + case "REFUND.SUCCESS", "REFUND.ABNORMAL", "REFUND.CLOSED": + var notifyData wechat.V3DecryptRefundResult + if err := notifyReq.DecryptCipherTextToStruct(c.config.Pay.ApiV3Key, ¬ifyData); err != nil { + return ctx.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cipher text"}) + } + log.Infof("Successfully decrypted cipher text for refund notify data: %+v", notifyData) + + if err := refundCallback(ctx, ¬ifyData); err != nil { + log.Errorf("refundCallback error:%v", err) + return err + } + } + + return ctx.Status(http.StatusOK).JSON(&wechat.V3NotifyRsp{ + Code: gopay.SUCCESS, + Message: "成功", + }) +} + +type BodyMap struct { + bm gopay.BodyMap +} + +func NewRefundBodyMap(c *w.Config) *BodyMap { + bm := make(gopay.BodyMap) + bm.Set("notify_url", c.Pay.NotifyURL) + return &BodyMap{ + bm: bm, + } +} + +func NewBodyMap(c *w.Config) *BodyMap { + bm := make(gopay.BodyMap) + bm.Set("appid", c.AppID). + Set("mchid", c.Pay.MchID). + Set("notify_url", c.Pay.NotifyURL) + // . + // SetBodyMap("amount", func(bm gopay.BodyMap) { + // bm.Set("total", 1). + // Set("currency", "CNY") + // }) + return &BodyMap{ + bm: bm, + } +} + +func (b *BodyMap) Set(key string, value interface{}) *BodyMap { + b.bm.Set(key, value) + return b +} + +func (b *BodyMap) SetBodyMap(key string, f func(bm gopay.BodyMap)) *BodyMap { + b.bm.SetBodyMap(key, f) + return b +} + +// Expire time +func (b *BodyMap) Expire(t time.Duration) *BodyMap { + return b.Set("time_expire", time.Now().Add(t).Format(time.RFC3339)) +} + +// Description +func (b *BodyMap) Description(desc string) *BodyMap { + return b.Set("description", desc) +} + +// OutTradeNo +func (b *BodyMap) OutTradeNo(outTradeNo string) *BodyMap { + return b.Set("out_trade_no", outTradeNo) +} + +// TransactionID +func (b *BodyMap) TransactionID(transactionID string) *BodyMap { + return b.Set("transaction_id", transactionID) +} + +// OutRefundNo +func (b *BodyMap) OutRefundNo(outRefundNo string) *BodyMap { + return b.Set("out_refund_no", outRefundNo) +} + +// RefundReason +func (b *BodyMap) RefundReason(refundReason string) *BodyMap { + return b.Set("reason", refundReason) +} + +// RefundAmount +func (b *BodyMap) RefundAmount(total, refund int64, currency CURRENCY) *BodyMap { + return b.SetBodyMap("amount", func(bm gopay.BodyMap) { + bm. + Set("total", total). + Set("refund", refund). + Set("currency", currency.String()) + }) +} + +func (b *BodyMap) CNYRefundAmount(total, refund int64) *BodyMap { + return b.RefundAmount(total, refund, CNY) +} + +type RefundGoodsInfo struct { + MerchantGoodsID string `json:"merchant_goods_id"` + GoodsName string `json:"goods_name"` + RefundQuantity int64 `json:"refund_quantity"` + RefundAmount int64 `json:"refund_amount"` + UnitPrice int64 `json:"unit_price"` +} + +// RefundGoodsInfo +func (b *BodyMap) RefundGoods(goods []RefundGoodsInfo) *BodyMap { + return b.Set("goods_detail", goods) +} + +// Amount +func (b *BodyMap) Amount(total int64, currency CURRENCY) *BodyMap { + return b.SetBodyMap("amount", func(bm gopay.BodyMap) { + bm. + Set("total", total). + Set("currency", currency.String()) + }) +} + +func (b *BodyMap) CNYAmount(total int64) *BodyMap { + return b.Amount(total, CNY) +} + +type GoodsInfo struct { + MerchantGoodsID string `json:"merchant_goods_id"` + GoodsName string `json:"goods_name"` + Quantity int64 `json:"quantity"` + UnitPrice int64 `json:"unit_price"` +} + +func (b *BodyMap) Detail(goods []GoodsInfo) *BodyMap { + return b.SetBodyMap("detail", func(bm gopay.BodyMap) { + bm.Set("goods_detail", goods) + }) +} + +// Payer +func (b *BodyMap) Payer(spOpenId string) *BodyMap { + return b.SetBodyMap("payer", func(bm gopay.BodyMap) { + bm.Set("openid", spOpenId) + }) +} + +// SubMchId +func (b *BodyMap) SubMchId(subMchId string) *BodyMap { + return b.Set("sub_mchid", subMchId) +} + +type CURRENCY string + +func (c CURRENCY) String() string { + return string(c) +} + +const ( + CNY CURRENCY = "CNY" + USD CURRENCY = "USD" + EUR CURRENCY = "EUR" +) diff --git a/backend_v1/providers/wepay/pay_test.go b/backend_v1/providers/wepay/pay_test.go new file mode 100644 index 0000000..1ca4e23 --- /dev/null +++ b/backend_v1/providers/wepay/pay_test.go @@ -0,0 +1,76 @@ +package wepay + +import ( + "context" + "fmt" + "testing" + "time" + + "quyun/app/service/testx" + + "github.com/go-pay/gopay/wechat/v3" + "github.com/go-pay/util/js" + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/suite" + "go.ipao.vip/atom/contracts" + "go.uber.org/dig" +) + +type WePayInjectParams struct { + dig.In + Initials []contracts.Initial `group:"initials"` + + Client *Client +} + +type WePayTestSuite struct { + suite.Suite + + WePayInjectParams +} + +func Test_WePay(t *testing.T) { + providers := testx.Default().With(Provide) + testx.Serve(providers, t, func(params WePayInjectParams) { + suite.Run(t, &WePayTestSuite{WePayInjectParams: params}) + }) +} + +func (s *WePayTestSuite) Test_PrePay() { + Convey("get prepay", s.T(), func() { + Convey("prepay", func() { + resp, err := s.Client.V3TransactionJsapi(context.Background(), func(bm *BodyMap) { + bm. + OutTradeNo(fmt.Sprintf("test_trade_no_%d", time.Now().Unix())). + Description("Test transaction"). + Payer("o5Bzk644x3LOMJsKSZRlqWin74IU") + }) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + s.T().Logf("prepay response: %+v", resp) + + sign, err := resp.PaySignOfJSAPI() + So(err, ShouldBeNil) + s.T().Logf("Sign: %+v", sign) + }) + }) +} + +func (s *WePayTestSuite) Test_parseNotify() { + Convey("parse notify", s.T(), func() { + Convey("prepay", func() { + content := `{"id":"43d17a94-eb1e-5641-bb11-f59e5b6e8749","summary":"支付成功","resource":{"nonce":"avbpSc2seCN5","algorithm":"AEAD_AES_256_GCM","ciphertext":"20VGA2uItmbqFvGBxBug2K3eORRyy/xYswoDA7v4+Yi2ArHnXCXzScVn6kD3ZVpKLiFY7zcTPpTxk2JFJF3vG/6WGG7uuD8DDK7keJk0PZoAfvmSPskQzieOVz3Tgmqp3SkE74mJHX1MeMZHMXMmzMJ4Mp1OmYD2YpiWsF7jlAtiGqxHSC//YlKGaJ/9r0QG4TwZcFpm+X4qkdBNX+DcSCjYeXGyWIm2bVujj63rO43DEA5x0nytdBSrpup/T85khZzNVue1EcyF5XY7PguePU3Q2o+e1c/LnoL9nN7S+n2ljm+nN3uCAhz8eqkPn4uowiq37Tw4JZ2rx2rXCb9jYKmt+I8JHpOij4SgX6oQd7fLeZHsbHC/05s0A1qdLzeF5AKgrAOQT/T1yQ+LsWTnY2ftXAP6mnqGE8Z+vQm5PGo8xsQ8AycVaAhwaRLFvn/XtwlkumfuduAojimFRSNElWwHcApnT+ekqzBrKnAvKo8hdeygf9QWHENcNWVwqwjUWIHe/fGWgJbc6u595bEHb4MkcI8ESD/6bpay/Wk6SyvZCJHqS1WWaPaU0xh9","original_type":"transaction","associated_data":"transaction"},"event_type":"TRANSACTION.SUCCESS","create_time":"2025-04-30T19:25:51+08:00","resource_type":"encrypt-resource"}` + var notifyReq wechat.V3NotifyReq + err := js.UnmarshalBytes([]byte(content), ¬ifyReq) + So(err, ShouldBeNil) + + s.T().Logf("notifyReq: %+v", notifyReq) + + var obj struct{} + err = notifyReq.DecryptCipherTextToStruct("5UBDkxVDY44AKafkqN6YgYxgtkXP6Mw6", &obj) + So(err, ShouldBeNil) + + s.T().Logf("Decrypted object: %+v", obj) + }) + }) +} diff --git a/curl.demo b/curl.demo new file mode 100644 index 0000000..26617d1 --- /dev/null +++ b/curl.demo @@ -0,0 +1,21 @@ +curl 'https://www.dtqp.site/wp-admin/admin-ajax.php' \ +-H 'accept: application/json, text/javascript, */*; q=0.01' \ +-H 'accept-language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,en-GB;q=0.6' \ +-H 'cache-control: no-cache' \ +-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundarylAgojW3uw58Gn9CK' \ +-b 'wordpress_sec_18ac11cb4ba3c4be6b302a55d73a44c1=ruarua%7C1767343396%7C7SKf6rU0Usr0CKTgRB8bz3VKqC9EbKTiFZc7MV5Eebe%7C18c9d53c900ea15bc4cc3d3a1ecb85a4ee1b27652760fef3c2e85d9428103366; fps_accelerat=60; PHPSESSID=1p65rsab13a23kr0cec4fph7ft; wordpress_logged_in_18ac11cb4ba3c4be6b302a55d73a44c1=ruarua%7C1767343396%7C7SKf6rU0Usr0CKTgRB8bz3VKqC9EbKTiFZc7MV5Eebe%7C8fdcd2ea1dd156481880506c1339d76fbc7b7d92f1501aa1b2209b3e8ca5d4f0' \ +-H 'dnt: 1' \ +-H 'origin: https://www.dtqp.site' \ +-H 'pragma: no-cache' \ +-H 'priority: u=1, i' \ +-H 'referer: https://www.dtqp.site/newposts/' \ +-H 'sec-ch-ua: "Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"' \ +-H 'sec-ch-ua-mobile: ?0' \ +-H 'sec-ch-ua-platform: "Windows"' \ +-H 'sec-fetch-dest: empty' \ +-H 'sec-fetch-mode: cors' \ +-H 'sec-fetch-site: same-origin' \ +-H 'sec-gpc: 1' \ +-H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0' \ +-H 'x-requested-with: XMLHttpRequest' \ +--data-raw $'------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="_wpnonce"\r\n\r\n6703c9b79e\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="file_type"\r\n\r\nvideo\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="file_size"\r\n\r\n16255488\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="file_name"\r\n\r\nEasyCLI-v0.1.32-windows-x64.mp4\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="file"; filename="EasyCLI-v0.1.32-windows-x64.mp4"\r\nContent-Type: video/mp4\r\n\r\n\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK\r\nContent-Disposition: form-data; name="action"\r\n\r\nuser_upload\r\n------WebKitFormBoundarylAgojW3uw58Gn9CK--\r\n'