feat: 完善移动端会员业务流程
This commit is contained in:
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -355,7 +355,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
||||
|
||||
func (s *Service) loginRateKey(phone string) string {
|
||||
digest := sha256.Sum256([]byte(phone))
|
||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
|
||||
+17
-14
@@ -8,15 +8,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/httpapi"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/httpapi"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ type App struct {
|
||||
redis *redis.Client
|
||||
api *httpapi.API
|
||||
frontendPath string
|
||||
upload config.UploadConfig
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
@@ -49,14 +51,15 @@ func New(cfg *config.Config) (*App, error) {
|
||||
taskService := tasks.New(st)
|
||||
mallService := mall.New(st)
|
||||
financeService := finance.New(st, redisClient, cfg.Auth)
|
||||
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService), frontendPath: cfg.FrontendDir}, nil
|
||||
attachmentService := attachments.New(st, cfg.Upload)
|
||||
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(strings.TrimRight(a.upload.PublicPath, "/")+"/", http.StripPrefix(strings.TrimRight(a.upload.PublicPath, "/"), http.FileServer(http.Dir(a.upload.Dir))))
|
||||
if a.frontendDir() != "" {
|
||||
mux.Handle("/admin", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/", frontendHandler(a.frontendDir()))
|
||||
}
|
||||
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
@@ -65,7 +68,7 @@ func (a *App) Handler() http.Handler {
|
||||
}
|
||||
|
||||
func (a *App) frontendDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
|
||||
if dir := strings.TrimSpace(os.Getenv("OLONS_SERVER_FRONTEND_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return strings.TrimSpace(a.frontendPath)
|
||||
@@ -75,7 +78,7 @@ func frontendHandler(dir string) http.Handler {
|
||||
files := http.FileServer(http.Dir(dir))
|
||||
index := filepath.Join(dir, "index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const maxImageSize = 5 << 20
|
||||
|
||||
var ErrImageInvalid = errors.New("image attachment invalid")
|
||||
|
||||
type Repository interface {
|
||||
CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
cfg config.UploadConfig
|
||||
}
|
||||
|
||||
func New(repository Repository, cfg config.UploadConfig) *Service {
|
||||
return &Service{repository: repository, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
|
||||
if s.repository == nil || memberID <= 0 {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1))
|
||||
if err != nil || len(data) == 0 || len(data) > maxImageSize {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
mimeType := http.DetectContentType(data)
|
||||
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
ext, ok := extensions[mimeType]
|
||||
if !ok {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err = rand.Read(random); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileName := hex.EncodeToString(random) + ext
|
||||
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(s.cfg.Dir, fileName)
|
||||
if err = os.WriteFile(path, data, 0o640); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := md5.Sum(data)
|
||||
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
|
||||
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
|
||||
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path,
|
||||
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
|
||||
}
|
||||
|
||||
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
||||
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
return "olons_server:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
}
|
||||
|
||||
func sessionKeyFromToken(pattern, token string) (string, error) {
|
||||
|
||||
@@ -9,18 +9,18 @@ import (
|
||||
|
||||
func TestSessionKeyFromToken(t *testing.T) {
|
||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
|
||||
key, err := sessionKeyFromToken("olons_server:session:token:%s", token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 {
|
||||
if !strings.HasPrefix(key, "olons_server:session:token:") || len(key) != len("olons_server:session:token:")+64 {
|
||||
t.Fatalf("unexpected session key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
|
||||
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
|
||||
if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
||||
if _, err := sessionKeyFromToken("olons_server:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
||||
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-19
@@ -18,10 +18,16 @@ type Config struct {
|
||||
MySQL MySQLConfig `json:"mysql"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
Upload UploadConfig `json:"upload"`
|
||||
ReadTimeout time.Duration `json:"-"`
|
||||
WriteTimeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
Dir string `json:"dir"`
|
||||
PublicPath string `json:"public_path"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
DSN string `json:"dsn"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
@@ -42,13 +48,14 @@ type AuthConfig struct {
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||
FinanceMasterKey string `json:"finance_master_key"`
|
||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||
RequiredAPIKey bool `json:"required_api_key"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" {
|
||||
if path := strings.TrimSpace(os.Getenv("OLONS_SERVER_CONFIG")); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,34 +82,38 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "workbench:session:token:%s",
|
||||
SessionKeyPattern: "olons_server:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
|
||||
ReadTimeout: 8 * time.Second,
|
||||
WriteTimeout: 8 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
setString(&cfg.Listen, "WORKBENCH_LISTEN")
|
||||
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
|
||||
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
|
||||
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
|
||||
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
|
||||
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
|
||||
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
|
||||
cfg.Auth.APIKeys = splitCSV(keys)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +144,9 @@ func (c *Config) validate() error {
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
|
||||
return errors.New("upload config is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+85
-102
@@ -19,8 +19,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,10 +34,8 @@ const (
|
||||
|
||||
var (
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
|
||||
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
|
||||
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
||||
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
@@ -68,29 +66,28 @@ type Service struct {
|
||||
}
|
||||
|
||||
type PaymentAccountInput struct {
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
AccountNo string `json:"account_no"`
|
||||
ReservedPhone string `json:"reserved_phone"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
TradePassword string `json:"trade_password"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
AccountNo string `json:"account_no"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
}
|
||||
|
||||
type PaymentAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
MobileMask string `json:"mobile_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
VerifyStatus string `json:"verify_status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
IdentityNoMask string `json:"identity_no_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
VerifyStatus string `json:"verify_status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type QuoteInput struct {
|
||||
@@ -235,10 +232,18 @@ type WalletLedgerPage struct {
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
|
||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||
return service
|
||||
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
|
||||
var encryptionKey, lookupKey []byte
|
||||
if len([]byte(masterKey)) >= 32 {
|
||||
encryptionKey = deriveKey(masterKey, "data-encryption")
|
||||
lookupKey = deriveKey(masterKey, "lookup-hmac")
|
||||
} else {
|
||||
var encryptionErr, lookupErr error
|
||||
encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey)
|
||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||
return service
|
||||
}
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
@@ -254,6 +259,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
||||
return service
|
||||
}
|
||||
|
||||
func deriveKey(masterKey, purpose string) []byte {
|
||||
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
@@ -273,36 +284,29 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
|
||||
if err != nil {
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holderCipher, err := s.encrypt(input.HolderName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountCipher, err := s.encrypt(input.AccountNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mobileCipher := ""
|
||||
if input.ReservedPhone != "" {
|
||||
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
|
||||
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
|
||||
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
|
||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher,
|
||||
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher,
|
||||
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
||||
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
||||
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
|
||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||
QRAttachmentID: input.QRAttachmentID,
|
||||
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -349,9 +353,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
reservedPhone := ""
|
||||
if account.MobileCipher != "" {
|
||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
||||
identityNo := ""
|
||||
if account.IdentityNoCipher != "" {
|
||||
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
@@ -359,8 +363,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
snapshot, err := json.Marshal(map[string]any{
|
||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||
"bank_name": account.BankName, "holder_name": holderName,
|
||||
"reserved_phone": reservedPhone, "account_no": accountNo,
|
||||
"qr_attachment_id": account.QRAttachmentID,
|
||||
"identity_no": identityNo, "account_no": accountNo,
|
||||
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -375,10 +380,12 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
|
||||
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
|
||||
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
|
||||
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
|
||||
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
|
||||
Channel: *channel,
|
||||
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
||||
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
|
||||
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
|
||||
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
|
||||
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
|
||||
Channel: *channel,
|
||||
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
||||
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
||||
BankName: account.BankName, HolderMask: account.HolderMask,
|
||||
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
|
||||
@@ -695,7 +702,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
||||
key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
||||
attempts, err := s.redis.Get(ctx, key).Int()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return nil, ErrUnavailable
|
||||
@@ -725,58 +732,27 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
return security, nil
|
||||
}
|
||||
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
|
||||
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
|
||||
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||
input.BankName = strings.TrimSpace(input.BankName)
|
||||
input.HolderName = strings.TrimSpace(input.HolderName)
|
||||
input.AccountNo = strings.TrimSpace(input.AccountNo)
|
||||
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
|
||||
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
switch input.AccountType {
|
||||
case "bank_card":
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
|
||||
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
|
||||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 1, nil
|
||||
case "digital_wallet":
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 2, nil
|
||||
case "unionpay":
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 3, nil
|
||||
case "alipay":
|
||||
input.BankName = "支付宝"
|
||||
input.BankCode = "ALIPAY"
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 4, nil
|
||||
case "wechat":
|
||||
input.BankName = "微信"
|
||||
input.BankCode = "WECHAT"
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 5, nil
|
||||
default:
|
||||
return 0, ErrAccountTypeInvalid
|
||||
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
||||
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
||||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
||||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
|
||||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
|
||||
return ErrAccountFieldsInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
|
||||
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
|
||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
|
||||
AccountNoMask: row.AccountNoMask, WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
@@ -1023,6 +999,13 @@ func maskAccount(value string) string {
|
||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func maskIdentityNo(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return strings.Repeat("*", len(value))
|
||||
}
|
||||
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func validHumanName(value string) bool {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -60,7 +60,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
|
||||
f.createdRecharge = input
|
||||
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
||||
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||
Status: 1, Remark: input.Remark}, nil
|
||||
Status: 2, Remark: input.Remark}, nil
|
||||
}
|
||||
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
||||
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
||||
@@ -101,14 +101,14 @@ func TestQuoteFailsClosed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizePaymentAccount(t *testing.T) {
|
||||
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
|
||||
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
if err != nil {
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" {
|
||||
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
|
||||
if result.Status != "reviewing" || result.RequestAmount != 50000 ||
|
||||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||
}
|
||||
@@ -219,3 +219,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
|
||||
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
|
||||
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
||||
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
|
||||
})
|
||||
if service.aead == nil || len(service.lookupKey) != 32 {
|
||||
t.Fatal("expected the finance keys to be derived from the master key")
|
||||
}
|
||||
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
|
||||
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
|
||||
t.Fatal("derived keys must be isolated by purpose")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
|
||||
+20
-15
@@ -3,25 +3,27 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
auth *auth.Authenticator
|
||||
accounts *accounts.Service
|
||||
portal *portal.Service
|
||||
tasks *tasks.Service
|
||||
mall *mall.Service
|
||||
finance *finance.Service
|
||||
auth *auth.Authenticator
|
||||
accounts *accounts.Service
|
||||
portal *portal.Service
|
||||
tasks *tasks.Service
|
||||
mall *mall.Service
|
||||
finance *finance.Service
|
||||
attachments *attachments.Service
|
||||
}
|
||||
|
||||
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API {
|
||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
|
||||
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service) *API {
|
||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService}
|
||||
}
|
||||
|
||||
func (a *API) Register(mux *http.ServeMux) {
|
||||
@@ -39,6 +41,8 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
||||
@@ -48,6 +52,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
||||
mux.HandleFunc("/v1/attachments/images", a.uploadImage)
|
||||
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
|
||||
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
|
||||
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
||||
@@ -58,5 +63,5 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
|
||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
)
|
||||
|
||||
func (a *API) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
result, err := a.attachments.UploadImage(r.Context(), principal.MemberID, header.Filename, file)
|
||||
if err != nil {
|
||||
if errors.Is(err, attachments.ErrImageInvalid) {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||
} else {
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,10 +3,11 @@ package httpapi
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
@@ -187,6 +188,56 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.portal.PopupNotice(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 2 || parts[1] != "read" {
|
||||
writePortalError(w, r, store.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
noticeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || noticeID <= 0 {
|
||||
writePortalError(w, r, store.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.portal.MarkNoticeRead(r.Context(), principal.TenantID, principal.MemberID, noticeID); err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, struct{}{})
|
||||
}
|
||||
|
||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type avatarRequest struct {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,6 +22,8 @@ type Repository interface {
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||
@@ -203,6 +205,20 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
|
||||
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||
row, err := s.repository.LatestPopupNotice(ctx, tenantID, memberID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt,
|
||||
CreatedAt: row.CreatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||
}
|
||||
|
||||
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -42,6 +42,15 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
if len(r.notices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &r.notices[0], nil
|
||||
}
|
||||
func (r fakeRepository) MarkMemberNoticeRead(context.Context, int64, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
|
||||
if r.noticePage == nil {
|
||||
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
|
||||
@@ -193,3 +202,16 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupNoticeMapsLatestUnreadNotice(t *testing.T) {
|
||||
service := New(fakeRepository{notices: []store.MemberNotice{{
|
||||
ID: 9, Title: "公告通知", Content: "新活动已上线", SourceType: 1,
|
||||
}}})
|
||||
notice, err := service.PopupNotice(context.Background(), 1, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notice == nil || notice.ID != 9 || notice.Content != "新活动已上线" || notice.SourceType != "real" {
|
||||
t.Fatalf("unexpected popup notice: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
||||
(tenant_id,member_no,username,mobile,password_hash,
|
||||
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
||||
credit_score,is_test_account,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,1,100,2,0,0)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
type AttachmentCreation struct {
|
||||
MemberID int64
|
||||
Name string
|
||||
MimeType string
|
||||
Path string
|
||||
URL string
|
||||
Size int64
|
||||
Ext string
|
||||
MD5 string
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
|
||||
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
|
||||
VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
|
||||
input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Attachment{ID: id, URL: input.URL}, nil
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +161,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberNotice
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice
|
||||
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("store is unavailable")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
||||
(tenant_id,notice_id,member_id,read_at,created_at)
|
||||
SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
|
||||
AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
|
||||
|
||||
var avatar Avatar
|
||||
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
|
||||
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
WHERE id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
|
||||
@@ -56,6 +56,8 @@ type RechargePage struct {
|
||||
Total int64
|
||||
}
|
||||
|
||||
const rechargeStatusReview = 2
|
||||
|
||||
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||
items := make([]RechargeChannel, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
@@ -95,7 +97,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
for _, attachmentID := range input.VoucherAttachmentIDs {
|
||||
var count int
|
||||
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
|
||||
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||
AND app_id='olons_server' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
@@ -109,8 +111,8 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
|
||||
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
|
||||
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,7 +125,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
}
|
||||
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
|
||||
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
|
||||
VoucherAttachmentIDs: vouchers, Status: rechargeStatusReview, Remark: input.Remark}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
|
||||
|
||||
+273
-53
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -104,12 +105,15 @@ type TaskStats struct {
|
||||
}
|
||||
|
||||
type TaskSubmissionInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
LedgerNo string
|
||||
IdempotencyKey string
|
||||
SettledAt time.Time
|
||||
}
|
||||
|
||||
type TaskSubmissionResult struct {
|
||||
@@ -159,9 +163,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
defer tx.Rollback()
|
||||
|
||||
var member struct {
|
||||
LevelID int64 `db:"level_id"`
|
||||
LevelID int64 `db:"level_id"`
|
||||
AutoDispatch int `db:"auto_dispatch"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
@@ -227,34 +232,16 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
return nil, ErrDailyLimitReached
|
||||
}
|
||||
}
|
||||
var task struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
PerMemberLimit int `db:"per_member_limit"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
|
||||
FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=?
|
||||
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL
|
||||
AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3))
|
||||
AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id
|
||||
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
|
||||
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id
|
||||
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit)
|
||||
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
dispatchMode, message := "manual", "等待后台匹配订单"
|
||||
if member.AutoDispatch == 1 {
|
||||
dispatchMode, message = "auto", "正在自动匹配订单"
|
||||
}
|
||||
rules, err := json.Marshal(map[string]any{
|
||||
"commission_amount": task.CommissionAmount,
|
||||
"commission_rate": task.CommissionRate,
|
||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
"member_level_id": member.LevelID,
|
||||
"source": "member_auto_claim",
|
||||
"dispatch_mode": dispatchMode,
|
||||
"hall_id": input.HallID,
|
||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
"member_level_id": member.LevelID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -262,10 +249,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
||||
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
|
||||
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
||||
message,order_status,freeze_status,matched_at,created_by,updated_by)
|
||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
|
||||
message,order_status,freeze_status,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
@@ -279,11 +265,148 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
|
||||
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
|
||||
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orderID int64, delaySeconds int, minAmount, maxAmount int64) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var order struct {
|
||||
OrderNo string `db:"order_no"`
|
||||
Status int `db:"order_status"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||
AutoDispatch int `db:"auto_dispatch"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT o.order_no,o.order_status,o.member_level_id,o.rule_snapshot,o.created_at,m.auto_dispatch
|
||||
FROM hg_trade_order o JOIN hg_app_member m ON m.tenant_id=o.tenant_id AND m.id=o.member_id
|
||||
WHERE o.tenant_id=? AND o.member_id=? AND o.id=? AND o.order_type=2 LIMIT 1 FOR UPDATE`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status != orderStatusMatching {
|
||||
return tx.Commit()
|
||||
}
|
||||
if order.AutoDispatch != 1 || delaySeconds > 0 && time.Now().UTC().Before(order.CreatedAt.UTC().Add(time.Duration(delaySeconds)*time.Second)) {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var account struct {
|
||||
ID int64 `db:"id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,version FROM hg_wallet_account
|
||||
WHERE tenant_id=? AND member_id=? AND currency='CNY' AND account_status=1 LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrAccountRestricted
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lowerAmount := max(int64(1), minAmount)
|
||||
upperAmount := account.AvailableAmount
|
||||
if maxAmount > 0 && maxAmount < upperAmount {
|
||||
upperAmount = maxAmount
|
||||
}
|
||||
if lowerAmount > upperAmount {
|
||||
return ErrNoTaskAvailable
|
||||
}
|
||||
var product struct {
|
||||
ID int64 `db:"id"`
|
||||
ProductNo string `db:"product_no"`
|
||||
Name string `db:"name"`
|
||||
PriceAmount int64 `db:"price_amount"`
|
||||
Currency string `db:"currency"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &product, `SELECT id,product_no,name,price_amount,currency FROM hg_product
|
||||
WHERE tenant_id=? AND publish_status=3 AND deleted_at IS NULL AND currency='CNY' AND price_amount>=? AND price_amount<=?
|
||||
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
|
||||
ORDER BY RAND() LIMIT 1 FOR UPDATE`, tenantID, lowerAmount, upperAmount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var levelRule struct {
|
||||
VersionID int64 `db:"version_id"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &levelRule, `SELECT v.id AS version_id,v.commission_rate
|
||||
FROM hg_app_member_level l JOIN hg_app_member_level_version v
|
||||
ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
|
||||
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, tenantID, order.MemberLevelID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrLevelNotEligible
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot := map[string]any{}
|
||||
if len(order.RuleSnapshot) > 0 {
|
||||
_ = json.Unmarshal(order.RuleSnapshot, &snapshot)
|
||||
}
|
||||
snapshot["match_source"] = "system_auto_match"
|
||||
snapshot["product_id"] = product.ID
|
||||
snapshot["product_no"] = product.ProductNo
|
||||
snapshot["unit_price_amount"] = product.PriceAmount
|
||||
snapshot["quantity"] = 1
|
||||
snapshot["level_version_id"] = levelRule.VersionID
|
||||
snapshot["commission_rate"] = levelRule.CommissionRate
|
||||
rules, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000
|
||||
afterAvailable := account.AvailableAmount - product.PriceAmount
|
||||
afterFrozen := account.FrozenAmount + product.PriceAmount
|
||||
walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
|
||||
updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`,
|
||||
afterAvailable, afterFrozen, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID),
|
||||
"task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, afterAvailable,
|
||||
"match:available", "自动派单扣除可用余额"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID),
|
||||
"task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, afterFrozen,
|
||||
"match:frozen", "自动派单冻结订单本金"); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1,
|
||||
unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?,
|
||||
order_status=2,freeze_status=1,matched_at=UTC_TIMESTAMP(3),updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name,
|
||||
product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules,
|
||||
"系统已自动匹配订单,订单本金已扣除,请在截止时间前完成", tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||
@@ -364,13 +487,15 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var order struct {
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
|
||||
err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
|
||||
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.OrderID)
|
||||
@@ -383,8 +508,48 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
var account struct {
|
||||
ID int64 `db:"id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
Status int `db:"account_status"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,account_status,version FROM hg_wallet_account
|
||||
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account.Status != 1 || account.FrozenAmount < order.TotalAmount {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if order.DeadlineAt != "" {
|
||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||
now := time.Now().UTC()
|
||||
afterAvailable := account.AvailableAmount + order.TotalAmount
|
||||
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
|
||||
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAvailable, afterFrozen,
|
||||
now, input.TenantID, account.ID, account.Version)
|
||||
if updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dEF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||
order.TotalAmount, account.FrozenAmount, afterFrozen, "expire:frozen", "订单超时释放冻结本金"); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dEA", input.OrderID), "task_principal_release", order.OrderNo, "available", 1,
|
||||
order.TotalAmount, account.AvailableAmount, afterAvailable, "expire:available", "订单超时退回订单本金"); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
||||
return nil, updateErr
|
||||
@@ -398,24 +563,64 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SubmissionNo == "" {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
attachments, err := json.Marshal(input.AttachmentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
now := input.SettledAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
principalAfter := account.AvailableAmount + order.TotalAmount
|
||||
afterAmount := principalAfter + order.CommissionAmount
|
||||
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||
accountResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,
|
||||
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAmount, afterFrozen,
|
||||
order.CommissionAmount, now, input.TenantID, account.ID, account.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := accountResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||
order.TotalAmount, account.FrozenAmount, afterFrozen, "settle:frozen", "任务完成释放冻结本金"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSA", input.OrderID), "task_principal_return", order.OrderNo, "available", 1,
|
||||
order.TotalAmount, account.AvailableAmount, principalAfter, "settle:available", "任务完成返回订单本金"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledgerID, err := insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSC", input.OrderID), "commission", input.SubmissionNo, "available", 1,
|
||||
order.CommissionAmount, principalAfter, afterAmount, "settle:commission", "任务完成自动返佣")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submissionResult, err := tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
|
||||
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
|
||||
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,4,?,10000,0,?,'AUTO_SETTLED','会员提交后自动结算',2,?,?,0,0,?,?)`,
|
||||
input.TenantID, input.SubmissionNo, input.OrderID, order.TaskID, input.MemberID, order.MemberLevelID,
|
||||
input.EvidenceText, attachments, order.CommissionAmount, now, ledgerID, now, now, now)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
|
||||
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=4,message='订单完成,佣金已入账',
|
||||
completed_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, now, now, input.TenantID, input.OrderID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +630,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusCompleted, SubmittedAt: now}, nil
|
||||
}
|
||||
|
||||
func insertTaskLedger(ctx context.Context, tx *sqlx.Tx, tenantID, memberID, accountID, orderID int64,
|
||||
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64,
|
||||
idempotencySuffix, remark string) (int64, error) {
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger
|
||||
(tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount,
|
||||
before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY',0,?,?,?,UTC_TIMESTAMP(3))`, tenantID, ledgerNo, accountID, memberID,
|
||||
bizType, bizNo, entryType, direction, amount, beforeAmount, afterAmount,
|
||||
fmt.Sprintf("task:%d:%s", orderID, idempotencySuffix), remark, memberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||
@@ -433,7 +653,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
|
||||
return nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
|
||||
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
AND tenant_id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+101
-83
@@ -10,39 +10,45 @@ import (
|
||||
)
|
||||
|
||||
type PaymentAccount struct {
|
||||
ID int64 `db:"id"`
|
||||
AccountType int `db:"account_type"`
|
||||
BankCode string `db:"bank_code"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||
VerifyStatus int `db:"verify_status"`
|
||||
IsDefault int `db:"is_default"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
ID int64 `db:"id"`
|
||||
AccountType int `db:"account_type"`
|
||||
BankCode string `db:"bank_code"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
IdentityNoCipher string `db:"identity_no_cipher"`
|
||||
IdentityNoHash string `db:"identity_no_hash"`
|
||||
IdentityNoMask string `db:"identity_no_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
|
||||
VerifyStatus int `db:"verify_status"`
|
||||
IsDefault int `db:"is_default"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
}
|
||||
|
||||
type PaymentAccountCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
MobileCipher string
|
||||
MobileMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
QRAttachmentID int64
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
IdentityNoCipher string
|
||||
IdentityNoHash string
|
||||
IdentityNoMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
WechatQRAttachmentID int64
|
||||
AlipayQRAttachmentID int64
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
@@ -67,30 +73,32 @@ type WithdrawalWallet struct {
|
||||
}
|
||||
|
||||
type WithdrawalCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
WithdrawalNo string
|
||||
PaymentAccountID int64
|
||||
ExpectedAccountType int
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedMobileCipher string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
ActualAmount int64
|
||||
Currency string
|
||||
AccountSnapshotCipher string
|
||||
AccountMask string
|
||||
BankName string
|
||||
HolderMask string
|
||||
AvailableLedgerNo string
|
||||
FrozenLedgerNo string
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
WithdrawalNo string
|
||||
PaymentAccountID int64
|
||||
ExpectedAccountType int
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedIdentityNoCipher string
|
||||
ExpectedIdentityNoHash string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedWechatQRAttachmentID int64
|
||||
ExpectedAlipayQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
ActualAmount int64
|
||||
Currency string
|
||||
AccountSnapshotCipher string
|
||||
AccountMask string
|
||||
BankName string
|
||||
HolderMask string
|
||||
AvailableLedgerNo string
|
||||
FrozenLedgerNo string
|
||||
}
|
||||
|
||||
type WithdrawalOrder struct {
|
||||
@@ -126,8 +134,10 @@ type WithdrawalPage struct {
|
||||
|
||||
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
items := make([]PaymentAccount, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
|
||||
@@ -143,25 +153,22 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
defer tx.Rollback()
|
||||
var memberID int64
|
||||
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
||||
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.QRAttachmentID > 0 {
|
||||
var attachmentCount int
|
||||
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||
input.TenantID, input.QRAttachmentID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attachmentCount != 1 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
var attachmentCount int
|
||||
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attachmentCount != 2 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
var accountCount int
|
||||
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
|
||||
@@ -173,12 +180,15 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
isDefault = 1
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
|
||||
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher,
|
||||
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
|
||||
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status,
|
||||
is_default,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
|
||||
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask,
|
||||
input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask,
|
||||
input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
|
||||
input.WechatQRAttachmentID, input.AlipayQRAttachmentID,
|
||||
isDefault, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
@@ -194,9 +204,10 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
return nil, err
|
||||
}
|
||||
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
|
||||
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
|
||||
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
|
||||
VerifyStatus: 1, IsDefault: isDefault}, nil
|
||||
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
|
||||
AccountNoMask: input.AccountNoMask, WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||
VerifyStatus: 2, IsDefault: isDefault}, nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
@@ -209,8 +220,10 @@ func nullableString(value string) any {
|
||||
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
|
||||
var row PaymentAccount
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
|
||||
@@ -289,8 +302,11 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
|
||||
var account PaymentAccount
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
|
||||
verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
|
||||
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -301,8 +317,10 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
}
|
||||
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
||||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
||||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
|
||||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
|
||||
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
|
||||
account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
|
||||
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
|
||||
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,6 +27,7 @@ type Repository interface {
|
||||
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
||||
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
|
||||
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
|
||||
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error
|
||||
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
|
||||
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
||||
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
||||
@@ -150,6 +152,9 @@ type SubmitResult struct {
|
||||
type taskPolicy struct {
|
||||
Enabled bool
|
||||
MinimumAmount int64
|
||||
AutoDelay int
|
||||
AutoMinAmount int64
|
||||
AutoMaxAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
@@ -274,6 +279,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.OrderStatus == 1 {
|
||||
policy, policyErr := s.policy(ctx, tenantID)
|
||||
if policyErr != nil {
|
||||
return nil, policyErr
|
||||
}
|
||||
if policy.Enabled {
|
||||
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
|
||||
return nil, err
|
||||
}
|
||||
if policy.Enabled && err == nil {
|
||||
row, err = s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -300,17 +323,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
|
||||
}
|
||||
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
||||
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
|
||||
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) {
|
||||
!validUniqueIDs(input.EvidenceAttachmentIDs) {
|
||||
return nil, ErrEvidenceInvalid
|
||||
}
|
||||
now := s.now().UTC()
|
||||
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
||||
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
|
||||
AttachmentIDs: input.EvidenceAttachmentIDs})
|
||||
AttachmentIDs: input.EvidenceAttachmentIDs, LedgerNo: newNumber("WL", now),
|
||||
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
|
||||
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
||||
}
|
||||
|
||||
@@ -331,6 +355,9 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
|
||||
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
||||
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
||||
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
||||
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
|
||||
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
|
||||
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -29,9 +30,20 @@ func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (
|
||||
}
|
||||
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
||||
f.claimed = input
|
||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY",
|
||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
|
||||
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
|
||||
}
|
||||
func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error {
|
||||
if f.order != nil && f.order.OrderStatus == 1 {
|
||||
f.order.OrderStatus = 2
|
||||
f.order.ProductID = 7
|
||||
f.order.ProductName = "自动匹配商品"
|
||||
f.order.Quantity = 1
|
||||
f.order.TotalAmount = 10000
|
||||
f.order.CommissionAmount = 100
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
|
||||
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
|
||||
}
|
||||
@@ -43,7 +55,7 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
|
||||
}
|
||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||
f.submitted = input
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4,
|
||||
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
|
||||
}
|
||||
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
|
||||
@@ -75,7 +87,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
func TestClaimBuildsMatchingOrder(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
||||
service := New(repository)
|
||||
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||
@@ -85,29 +97,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending" || repository.claimed.HallID != 2 ||
|
||||
if result.Status != "matching" || repository.claimed.HallID != 2 ||
|
||||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
|
||||
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) {
|
||||
t.Fatalf("expected evidence error, got %v", err)
|
||||
}
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
|
||||
if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" {
|
||||
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitStillValidatesOptionalEvidence(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
tooLong := strings.Repeat("字", 2001)
|
||||
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) {
|
||||
t.Fatalf("expected evidence error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||
|
||||
Reference in New Issue
Block a user