feat: live module and jenkins pipeline to harbor deploy
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
// rechargeOrderV1 freezes the original schema owned by the first migration.
|
||||
type rechargeOrderV1 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
OrderNo string `gorm:"size:80;uniqueIndex"`
|
||||
UserID uint `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
RateCents int64
|
||||
PaymentMethod string `gorm:"size:40;index"`
|
||||
Status string `gorm:"size:30;index"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
func (rechargeOrderV1) TableName() string { return "live_recharge_orders" }
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190003_recharge_schema", Migrate: func(db *gorm.DB) error {
|
||||
if err := migration.CreateMissingTables(db, &ledger.RechargePlanPO{}, &rechargeOrderV1{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&ledger.RechargePlanPO{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]ledger.RechargePlanPO{{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}, {Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true}, {Sort: 3, Diamonds: 980, AmountCents: 9800, Enabled: true}, {Sort: 4, Diamonds: 1980, AmountCents: 19800, Enabled: true}, {Sort: 5, Diamonds: 3280, AmountCents: 32800, Enabled: true}, {Sort: 6, Diamonds: 6980, AmountCents: 69800, Enabled: true}}).Error
|
||||
}}, {ID: "202609190007_recharge_payment_unification", Migrate: func(db *gorm.DB) error {
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasColumn(&ledger.RechargeOrderPO{}, "CreditedAt") {
|
||||
if err := migrator.AddColumn(&ledger.RechargeOrderPO{}, "CreditedAt"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"payment_method", "status", "completed_at"} {
|
||||
if migrator.HasColumn(&rechargeOrderV1{}, column) {
|
||||
if err := migrator.DropColumn(&rechargeOrderV1{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package recharge
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,191 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
|
||||
|
||||
func (r *repo) Plans(ctx context.Context) ([]*biz.Plan, error) {
|
||||
var rows []ledger.RechargePlanPO
|
||||
if err := r.db(ctx).Where("enabled = ?", true).Order("sort ASC,id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.Plan, 0, len(rows))
|
||||
for _, v := range rows {
|
||||
out = append(out, &biz.Plan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
|
||||
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ledger.RechargePlanPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range plans {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ledger.RechargeOrderPO
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
Provider string `gorm:"column:provider"`
|
||||
ProviderTradeNo string `gorm:"column:provider_trade_no"`
|
||||
PaymentStatus string `gorm:"column:payment_status"`
|
||||
FulfillmentStatus string `gorm:"column:fulfillment_status"`
|
||||
RefundStatus string `gorm:"column:refund_status"`
|
||||
}
|
||||
|
||||
func query(db *gorm.DB, f biz.Filter) *gorm.DB {
|
||||
q := db.Model(&ledger.RechargeOrderPO{}).
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_recharge_orders.user_id").
|
||||
Joins("LEFT JOIN pay_orders ON pay_orders.business_type = ? AND pay_orders.business_id = live_recharge_orders.order_no AND pay_orders.trade_no = live_recharge_orders.order_no", biz.BusinessType)
|
||||
if f.OrderNo != "" {
|
||||
q = q.Where("live_recharge_orders.order_no = ?", f.OrderNo)
|
||||
}
|
||||
if f.UserKeyword != "" {
|
||||
v := "%" + f.UserKeyword + "%"
|
||||
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
|
||||
}
|
||||
if f.PaymentStatus != "" {
|
||||
q = q.Where("pay_orders.payment_status = ?", f.PaymentStatus)
|
||||
}
|
||||
if f.Provider != "" {
|
||||
q = q.Where("pay_orders.provider = ?", f.Provider)
|
||||
}
|
||||
if len(f.CreatedAt) == 2 {
|
||||
q = q.Where("live_recharge_orders.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
const orderSelect = "live_recharge_orders.*,sys_users.username,sys_users.nick_name AS nickname,pay_orders.provider,pay_orders.provider_trade_no,pay_orders.payment_status,pay_orders.fulfillment_status,pay_orders.refund_status"
|
||||
|
||||
func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.Order, int64, *biz.Summary, error) {
|
||||
q := query(r.db(ctx), f)
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var s struct{ PaidCount, AmountCents int64 }
|
||||
if err := query(r.db(ctx), f).Select("COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN 1 ELSE 0 END),0) AS paid_count,COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN live_recharge_orders.amount_cents ELSE 0 END),0) AS amount_cents").Scan(&s).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var rows []row
|
||||
if err := q.Select(orderSelect).Order(orderBy(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
out := make([]*biz.Order, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
}
|
||||
return out, total, &biz.Summary{Count: total, PaidCount: s.PaidCount, AmountCents: s.AmountCents}, nil
|
||||
}
|
||||
|
||||
func (r *repo) Get(ctx context.Context, no string) (*biz.Order, error) {
|
||||
var v row
|
||||
if err := query(r.db(ctx), biz.Filter{OrderNo: no}).Select(orderSelect).Scan(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.ID == 0 {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
return toBiz(&v), nil
|
||||
}
|
||||
|
||||
func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, error) {
|
||||
var plan ledger.RechargePlanPO
|
||||
if err := r.db(ctx).Where("id = ? AND enabled = ?", planID, true).First(&plan).Error; err != nil {
|
||||
return nil, biz.ErrInvalid
|
||||
}
|
||||
rate := int64(10)
|
||||
var set ledger.SettingPO
|
||||
if err := r.db(ctx).Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil {
|
||||
if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil {
|
||||
rate = v
|
||||
}
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
if err := r.db(ctx).Create(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, v.OrderNo)
|
||||
}
|
||||
|
||||
func (r *repo) MarkPaid(ctx context.Context, no string) (*biz.Order, error) {
|
||||
var credited ledger.RechargeOrderPO
|
||||
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var v ledger.RechargeOrderPO
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", no).First(&v).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return biz.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if v.CreditedAt != nil {
|
||||
credited = v
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
v.CreditedAt = &now
|
||||
if err := tx.Save(&v).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: v.UserID}
|
||||
if err := tx.Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance + ?", v.Diamonds), "total_recharge_cents": gorm.Expr("total_recharge_cents + ?", v.AmountCents)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
credited = v
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(&row{RechargeOrderPO: credited}), nil
|
||||
}
|
||||
|
||||
func toBiz(v *row) *biz.Order {
|
||||
return &biz.Order{
|
||||
ID: v.ID, OrderNo: v.OrderNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname,
|
||||
Diamonds: v.Diamonds, AmountCents: v.AmountCents, Provider: v.Provider, ProviderTradeNo: v.ProviderTradeNo,
|
||||
PaymentStatus: v.PaymentStatus, FulfillmentStatus: v.FulfillmentStatus, RefundStatus: v.RefundStatus,
|
||||
CreatedAt: v.CreatedAt, CreditedAt: v.CreditedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func orderBy(v string) string {
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "live_recharge_orders.created_at DESC"
|
||||
}
|
||||
|
||||
func number(p string) string {
|
||||
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func TestMarkPaidCreditsWalletOnlyOnce(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-1", UserID: 3, Diamonds: 60, AmountCents: 600}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.DiamondBalance != 60 || wallet.TotalRechargeCents != 600 {
|
||||
t.Fatalf("wallet credited more than once: %+v", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creditedAt := time.Now()
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-2", UserID: 3, Diamonds: 300, AmountCents: 3000, CreditedAt: &creditedAt}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
pending, err := repo.Get(context.Background(), order.OrderNo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Provider != "" || pending.PaymentStatus != "" {
|
||||
t.Fatalf("new business order unexpectedly has payment state: %+v", pending)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO pay_orders(trade_no,business_type,business_id,provider,provider_trade_no,payment_status,fulfillment_status,refund_status) VALUES (?,?,?,?,?,?,?,?)`, order.OrderNo, biz.BusinessType, order.OrderNo, "wechat-v3", "WX-1", "paid", "succeeded", "none").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, summary, err := repo.List(context.Background(), biz.Filter{Provider: "wechat-v3", PaymentStatus: "paid"}, biz.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || summary.PaidCount != 1 || summary.AmountCents != 3000 {
|
||||
t.Fatalf("unexpected list result: total=%d items=%d summary=%+v", total, len(items), summary)
|
||||
}
|
||||
item := items[0]
|
||||
if item.Provider != "wechat-v3" || item.ProviderTradeNo != "WX-1" || item.PaymentStatus != "paid" || item.FulfillmentStatus != "succeeded" || item.RefundStatus != "none" {
|
||||
t.Fatalf("payment state was not joined: %+v", item)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user