feat: migrate workbench services
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type profileRepository struct {
|
||||
overview *store.MemberOverview
|
||||
security *store.AccountSecurity
|
||||
loginPasswordHash string
|
||||
tradePasswordHash string
|
||||
}
|
||||
|
||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||
func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return r.overview, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil
|
||||
}
|
||||
func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return r.security, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.loginPasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.tradePasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProfileMapsMemberCategory(t *testing.T) {
|
||||
repository := &profileRepository{overview: &store.MemberOverview{
|
||||
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
|
||||
Currency: "CNY", WalletStatus: 1,
|
||||
}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
profile, err := service.Profile(context.Background(), 0, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
|
||||
t.Fatalf("unexpected profile states: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordVerifiesAndHashes(t *testing.T) {
|
||||
currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "old-secret", NewPassword: "new-secret",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil {
|
||||
t.Fatal("new login password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) {
|
||||
currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "wrong-secret", NewPassword: "new-secret",
|
||||
})
|
||||
if !errors.Is(err, ErrCurrentPassword) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrCurrentPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTradePasswordRequiresSixDigits(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
for _, password := range []string{"", "12345", "1234567", "abcdef"} {
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: password,
|
||||
})
|
||||
if !errors.Is(err, ErrTradePassword) {
|
||||
t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword)
|
||||
}
|
||||
}
|
||||
if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil {
|
||||
t.Fatal("trade password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeTradePasswordRequiresOldPassword(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{
|
||||
PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash),
|
||||
}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
})
|
||||
if !errors.Is(err, ErrOldTradePasswordUnset) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("account service unavailable")
|
||||
ErrUsernameInvalid = errors.New("username invalid")
|
||||
ErrPhoneInvalid = errors.New("phone invalid")
|
||||
ErrPasswordInvalid = errors.New("password invalid")
|
||||
ErrReferralInvalid = errors.New("referral code invalid")
|
||||
ErrPrivacyNotAccepted = errors.New("privacy policy not accepted")
|
||||
ErrRateLimited = errors.New("login rate limited")
|
||||
ErrCurrentPassword = errors.New("current password invalid")
|
||||
ErrLoginPassword = errors.New("login password invalid")
|
||||
ErrTradePassword = errors.New("trade password invalid")
|
||||
ErrOldTradePassword = errors.New("old trade password invalid")
|
||||
ErrOldTradePasswordUnset = errors.New("old trade password is required")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
)
|
||||
|
||||
var (
|
||||
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
|
||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`)
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||
AccountByMobileHash(context.Context, int64, string) (*store.Account, error)
|
||||
RecordLogin(context.Context, int64, int64) error
|
||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
||||
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
||||
UpdateLoginPassword(context.Context, int64, int64, string, string) error
|
||||
UpdateTradePassword(context.Context, int64, int64, string, string) error
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username string
|
||||
Phone string
|
||||
Password string
|
||||
ReferralCode string
|
||||
PrivacyAccepted bool
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Phone string
|
||||
Password string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Account *store.Account
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
MemberID int64 `json:"member_id"`
|
||||
MemberNo string `json:"member_no"`
|
||||
Username string `json:"username"`
|
||||
PhoneMask string `json:"phone_mask"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Level ProfileLevel `json:"level"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
MemberCategory string `json:"member_category"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
Wallet ProfileWallet `json:"wallet"`
|
||||
}
|
||||
|
||||
type ProfileLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
}
|
||||
|
||||
type ProfileWallet struct {
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
TotalIncome int64 `json:"total_income"`
|
||||
TotalExpense int64 `json:"total_expense"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type AvatarResult struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
URL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type ChangePasswordInput struct {
|
||||
OldPassword string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type ChangeTradePasswordInput struct {
|
||||
LoginPassword string
|
||||
OldTradePassword string
|
||||
NewTradePassword string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
cfg config.AuthConfig
|
||||
aead cipher.AEAD
|
||||
lookupKey []byte
|
||||
ready bool
|
||||
}
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
service := &Service{repository: repository, redis: redisClient, cfg: cfg}
|
||||
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 {
|
||||
return service
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
service.aead = aead
|
||||
service.lookupKey = lookupKey
|
||||
service.ready = repository != nil && redisClient != nil
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.Phone = strings.TrimSpace(input.Phone)
|
||||
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
||||
if !usernamePattern.MatchString(input.Username) {
|
||||
return nil, ErrUsernameInvalid
|
||||
}
|
||||
if !phonePattern.MatchString(input.Phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
||||
return nil, ErrPasswordInvalid
|
||||
}
|
||||
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
if !input.PrivacyAccepted {
|
||||
return nil, ErrPrivacyNotAccepted
|
||||
}
|
||||
|
||||
normalizedPhone := "+86" + input.Phone
|
||||
mobileCipher, err := s.encrypt(normalizedPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mobileHash := s.lookupHash(normalizedPhone)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberNo, err := newPublicID("M", 21)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inviteCode, err := newPublicID("", 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
MobileCipher: mobileCipher,
|
||||
MobileHash: mobileHash,
|
||||
MobileMask: maskPhone(input.Phone),
|
||||
PasswordHash: string(passwordHash),
|
||||
ReferralCode: input.ReferralCode,
|
||||
InviteCode: inviteCode,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrReferralInvalid) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(ctx, account, input.DeviceID)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
phone := strings.TrimSpace(input.Phone)
|
||||
if !phonePattern.MatchString(phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if input.Password == "" || len([]byte(input.Password)) > 72 {
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
mobileHash := s.lookupHash("+86" + phone)
|
||||
rateKey := s.loginRateKey(mobileHash)
|
||||
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
account, err := s.repository.AccountByMobileHash(ctx, s.cfg.DefaultTenantID, mobileHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.issueSession(ctx, account, input.DeviceID)
|
||||
if err == nil {
|
||||
_ = s.redis.Del(ctx, rateKey).Err()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
row, err := s.repository.MemberOverview(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Profile{
|
||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||
PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
|
||||
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
|
||||
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
|
||||
CreditScore: row.CreditScore,
|
||||
Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
||||
TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency,
|
||||
Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if attachmentID <= 0 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID)
|
||||
if errors.Is(err, store.ErrAttachmentInvalid) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if !tradePasswordPattern.MatchString(input.NewTradePassword) {
|
||||
return ErrTradePassword
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if security.TradePasswordHash != "" {
|
||||
if input.OldTradePassword == "" {
|
||||
return ErrOldTradePasswordUnset
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil {
|
||||
return ErrTradePassword
|
||||
}
|
||||
} else if input.OldTradePassword != "" {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"member_id": account.MemberID,
|
||||
"tenant_id": account.TenantID,
|
||||
"device_id": strings.TrimSpace(deviceID),
|
||||
"state": "active",
|
||||
"issued_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) encrypt(value string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
||||
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupHash(value string) string {
|
||||
mac := hmac.New(sha256.New, s.lookupKey)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Service) loginRateKey(mobileHash string) string {
|
||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + mobileHash
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
pipe := s.redis.TxPipeline()
|
||||
pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 15*time.Minute)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func sessionKey(pattern string, rawToken []byte) string {
|
||||
digest := sha256.Sum256(rawToken)
|
||||
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) != 11 {
|
||||
return ""
|
||||
}
|
||||
return phone[:3] + "****" + phone[7:]
|
||||
}
|
||||
|
||||
func newPublicID(prefix string, length int) (string, error) {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
raw := make([]byte, length-len(prefix))
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, len(raw))
|
||||
for i, value := range raw {
|
||||
result[i] = alphabet[int(value)%len(alphabet)]
|
||||
}
|
||||
return prefix + string(result), nil
|
||||
}
|
||||
|
||||
func memberStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func memberCategoryName(category int) string {
|
||||
if category == 2 {
|
||||
return "fake"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
|
||||
func walletStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
|
||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
input RegisterInput
|
||||
want error
|
||||
}{
|
||||
{name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
|
||||
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
|
||||
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
|
||||
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
||||
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := s.Register(context.Background(), test.input)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("Register() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileEncryptionRoundTrip(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{aead: aead, lookupKey: []byte("01234567890123456789012345678901")}
|
||||
encrypted, err := s.encrypt("+8613800138000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
|
||||
if err != nil || string(plain) != "+8613800138000" {
|
||||
t.Fatalf("decrypt = %q, err=%v", plain, err)
|
||||
}
|
||||
if len(s.lookupHash("+8613800138000")) != 64 {
|
||||
t.Fatal("mobile lookup hash must be hex sha256")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRequiresMainlandPhone(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} {
|
||||
_, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"})
|
||||
if !errors.Is(err, ErrPhoneInvalid) {
|
||||
t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("ai provider is not configured")
|
||||
ErrProvider = errors.New("ai provider failed")
|
||||
ErrTimeout = errors.New("ai provider timeout")
|
||||
ErrBadOutput = errors.New("ai provider returned invalid output")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg config.AIConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type GenerateRequest struct {
|
||||
Feature string
|
||||
Model string
|
||||
Messages []Message
|
||||
Temperature float64
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
type GenerateResponse struct {
|
||||
ProviderRequestID string
|
||||
Model string
|
||||
Content string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
FinishReason string
|
||||
LatencyMS int64
|
||||
}
|
||||
|
||||
func NewClient(cfg config.AIConfig) *Client {
|
||||
timeout := time.Duration(cfg.DeepSeek.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &Client{cfg: cfg, httpClient: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c != nil && strings.TrimSpace(c.cfg.DeepSeek.APIKey) != ""
|
||||
}
|
||||
|
||||
func (c *Client) DefaultModel() string {
|
||||
if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" {
|
||||
return "deepseek-flash"
|
||||
}
|
||||
return strings.TrimSpace(c.cfg.DefaultModel)
|
||||
}
|
||||
|
||||
func (c *Client) ComplexModel() string {
|
||||
if c == nil || strings.TrimSpace(c.cfg.ComplexModel) == "" {
|
||||
return c.DefaultModel()
|
||||
}
|
||||
return strings.TrimSpace(c.cfg.ComplexModel)
|
||||
}
|
||||
|
||||
func (c *Client) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
|
||||
if c == nil || strings.TrimSpace(c.cfg.DeepSeek.APIKey) == "" {
|
||||
return GenerateResponse{}, ErrNotConfigured
|
||||
}
|
||||
model := strings.TrimSpace(req.Model)
|
||||
if model == "" {
|
||||
model = c.DefaultModel()
|
||||
}
|
||||
temperature := req.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = c.cfg.Temperature
|
||||
}
|
||||
if temperature == 0 {
|
||||
temperature = 0.2
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = c.cfg.MaxOutputTokens
|
||||
}
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"messages": req.Messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": maxTokens,
|
||||
"stream": false,
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return GenerateResponse{}, err
|
||||
}
|
||||
baseURL := strings.TrimRight(firstNonEmpty(c.cfg.DeepSeek.BaseURL, "https://api.deepseek.com"), "/")
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return GenerateResponse{}, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.DeepSeek.APIKey))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return GenerateResponse{}, ErrTimeout
|
||||
}
|
||||
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrProvider, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
|
||||
return GenerateResponse{}, ErrTimeout
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return GenerateResponse{}, fmt.Errorf("%w: deepseek status %d: %s", ErrProvider, resp.StatusCode, string(raw))
|
||||
}
|
||||
var parsed chatCompletionResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return GenerateResponse{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return GenerateResponse{}, ErrBadOutput
|
||||
}
|
||||
content := parsed.Choices[0].Message.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return GenerateResponse{}, ErrBadOutput
|
||||
}
|
||||
return GenerateResponse{
|
||||
ProviderRequestID: parsed.ID,
|
||||
Model: firstNonEmpty(parsed.Model, model),
|
||||
Content: content,
|
||||
PromptTokens: parsed.Usage.PromptTokens,
|
||||
CompletionTokens: parsed.Usage.CompletionTokens,
|
||||
TotalTokens: parsed.Usage.TotalTokens,
|
||||
FinishReason: parsed.Choices[0].FinishReason,
|
||||
LatencyMS: time.Since(start).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExtractJSONObject(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(text, "```") {
|
||||
text = strings.TrimPrefix(text, "```json")
|
||||
text = strings.TrimPrefix(text, "```")
|
||||
text = strings.TrimSuffix(text, "```")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start >= 0 && end >= start {
|
||||
return text[start : end+1]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
ProviderRequestID string
|
||||
Model string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
LatencyMS int64
|
||||
}
|
||||
|
||||
type PolishResult struct {
|
||||
Text string
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
type DocumentResult struct {
|
||||
Title string
|
||||
ContentMarkdown string
|
||||
ContentPlain string
|
||||
Sections []DocumentSection
|
||||
Summary string
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
type DocumentSection struct {
|
||||
Heading string `json:"heading"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type TableAnalysisInput struct {
|
||||
Topic string `json:"topic"`
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Profile TableProfile `json:"profile"`
|
||||
}
|
||||
|
||||
type TableAnalysisResult struct {
|
||||
Title string
|
||||
Metrics []TableMetric
|
||||
Preview TablePreview
|
||||
Alerts []TableAlert
|
||||
Interpretation string
|
||||
Summary string
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
type TableMetric struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
ChangeText string `json:"change_text"`
|
||||
Trend string `json:"trend"`
|
||||
}
|
||||
|
||||
type TablePreview struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
TotalRows int `json:"total_rows"`
|
||||
}
|
||||
|
||||
type TableAlert struct {
|
||||
Level string `json:"level"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type TableProfile struct {
|
||||
Columns any `json:"columns,omitempty"`
|
||||
SampleRows any `json:"sample_rows,omitempty"`
|
||||
RowCount int `json:"row_count,omitempty"`
|
||||
Stats any `json:"stats,omitempty"`
|
||||
Outliers any `json:"outliers,omitempty"`
|
||||
Parsed bool `json:"parsed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type MeetingSummaryInput struct {
|
||||
MeetingTitle string
|
||||
StartAt string
|
||||
EndAt string
|
||||
Participants []string
|
||||
Files []string
|
||||
}
|
||||
|
||||
type MeetingSummaryResult struct {
|
||||
Conclusions []string
|
||||
Decisions []string
|
||||
ActionItems []MeetingActionItem
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
type MeetingActionItem struct {
|
||||
OwnerName string `json:"owner_name"`
|
||||
Content string `json:"content"`
|
||||
DueText string `json:"due_text"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func NewService(client *Client) *Service {
|
||||
return &Service{client: client}
|
||||
}
|
||||
|
||||
func (s *Service) Configured() bool {
|
||||
return s != nil && s.client != nil && s.client.Configured()
|
||||
}
|
||||
|
||||
func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return PolishResult{}, ErrNotConfigured
|
||||
}
|
||||
if maxOutputChars <= 0 {
|
||||
maxOutputChars = 300
|
||||
}
|
||||
system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。"
|
||||
user := fmt.Sprintf(`请润色下面的中文工作文案。
|
||||
|
||||
要求:
|
||||
1. 语气:%s。
|
||||
2. 场景:%s。
|
||||
3. 输出不超过 %d 个中文字符。
|
||||
4. 保持事实不变,不编造数据、人名、时间和结论。
|
||||
5. 只输出 JSON:{"polished_text":"..."}。
|
||||
|
||||
原文:
|
||||
%s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText)
|
||||
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||
Feature: "text_polish",
|
||||
Model: s.client.DefaultModel(),
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
MaxTokens: maxOutputChars * 3,
|
||||
})
|
||||
if err != nil {
|
||||
return PolishResult{}, err
|
||||
}
|
||||
var out struct {
|
||||
PolishedText string `json:"polished_text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||
return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||
}
|
||||
out.PolishedText = strings.TrimSpace(out.PolishedText)
|
||||
if out.PolishedText == "" {
|
||||
return PolishResult{}, ErrBadOutput
|
||||
}
|
||||
return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return DocumentResult{}, ErrNotConfigured
|
||||
}
|
||||
system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||
user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。
|
||||
|
||||
要求:
|
||||
1. 输出语言:%s。
|
||||
2. 文档类型:%s。
|
||||
3. 标题提示:%s。
|
||||
4. 内容要忠于原文,不编造事实。
|
||||
5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。
|
||||
6. content_markdown 要适合直接展示,使用二级标题和列表。
|
||||
7. 只输出 JSON,格式:
|
||||
{
|
||||
"title": "string",
|
||||
"summary": "string",
|
||||
"content_markdown": "string",
|
||||
"content_plain": "string",
|
||||
"sections": [{"heading":"string","content":"string"}]
|
||||
}
|
||||
|
||||
用户输入:
|
||||
%s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText)
|
||||
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||
Feature: "smart_document",
|
||||
Model: s.client.DefaultModel(),
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
MaxTokens: 2400,
|
||||
})
|
||||
if err != nil {
|
||||
return DocumentResult{}, err
|
||||
}
|
||||
usage := usageFromResponse(resp)
|
||||
var out struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
ContentMarkdown string `json:"content_markdown"`
|
||||
ContentPlain string `json:"content_plain"`
|
||||
Sections []DocumentSection `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||
return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出格式异常,已使用兜底文档。"), nil
|
||||
}
|
||||
out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档"))
|
||||
out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown)
|
||||
if out.ContentMarkdown == "" && len(out.Sections) > 0 {
|
||||
out.ContentMarkdown = markdownFromSections(out.Sections)
|
||||
}
|
||||
if strings.TrimSpace(out.ContentPlain) == "" {
|
||||
out.ContentPlain = plainFromSections(out.Sections)
|
||||
}
|
||||
if out.ContentMarkdown == "" {
|
||||
return fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出缺少文档正文,已使用兜底文档。"), nil
|
||||
}
|
||||
return DocumentResult{
|
||||
Title: out.Title,
|
||||
ContentMarkdown: out.ContentMarkdown,
|
||||
ContentPlain: strings.TrimSpace(out.ContentPlain),
|
||||
Sections: out.Sections,
|
||||
Summary: strings.TrimSpace(out.Summary),
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fallbackDocument(sourceText, titleHint, documentType string, usage Usage, reason string) DocumentResult {
|
||||
title := firstNonEmpty(titleHint, documentTypeTitle(documentType), excerptPlain(sourceText, 30), "智能文档")
|
||||
source := strings.TrimSpace(sourceText)
|
||||
if source == "" {
|
||||
source = "用户未提供有效原文。"
|
||||
}
|
||||
sections := []DocumentSection{
|
||||
{Heading: "处理说明", Content: reason},
|
||||
{Heading: "用户输入", Content: source},
|
||||
{Heading: "后续建议", Content: "建议检查原始输入是否完整,或重新发起生成以获取更完整的智能文档。"},
|
||||
}
|
||||
contentMarkdown := markdownFromSections(sections)
|
||||
return DocumentResult{
|
||||
Title: title,
|
||||
ContentMarkdown: contentMarkdown,
|
||||
ContentPlain: plainFromSections(sections),
|
||||
Sections: sections,
|
||||
Summary: excerptPlain(source, 120),
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func documentTypeTitle(documentType string) string {
|
||||
switch strings.TrimSpace(documentType) {
|
||||
case "meeting_minutes":
|
||||
return "会议纪要"
|
||||
case "speech":
|
||||
return "演讲稿"
|
||||
case "proposal":
|
||||
return "方案文档"
|
||||
case "summary":
|
||||
return "内容总结"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return TableAnalysisResult{}, ErrNotConfigured
|
||||
}
|
||||
system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||
profileJSON, _ := json.Marshal(input.Profile)
|
||||
user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。
|
||||
|
||||
注意:
|
||||
1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。
|
||||
2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。
|
||||
|
||||
报表主题:%s
|
||||
文件名:%s
|
||||
文件类型:%s
|
||||
文件大小:%d bytes
|
||||
表格画像:%s
|
||||
|
||||
只输出 JSON:
|
||||
{
|
||||
"title": "string",
|
||||
"summary": "string",
|
||||
"metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}],
|
||||
"preview": {"columns":["string"],"rows":[["string"]],"total_rows":0},
|
||||
"alerts": [{"level":"info|warning|danger","title":"string","description":"string"}],
|
||||
"interpretation": "string"
|
||||
}`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON))
|
||||
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||
Feature: "smart_table",
|
||||
Model: s.client.ComplexModel(),
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
MaxTokens: 1800,
|
||||
})
|
||||
if err != nil {
|
||||
return TableAnalysisResult{}, err
|
||||
}
|
||||
usage := usageFromResponse(resp)
|
||||
var out struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Metrics []TableMetric `json:"metrics"`
|
||||
Preview TablePreview `json:"preview"`
|
||||
Alerts []TableAlert `json:"alerts"`
|
||||
Interpretation string `json:"interpretation"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||
return fallbackTableAnalysis(input, usage, "AI 输出格式异常,已使用兜底解读。"), nil
|
||||
}
|
||||
out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告")
|
||||
if strings.TrimSpace(out.Interpretation) == "" {
|
||||
return fallbackTableAnalysis(input, usage, "AI 输出缺少核心解读,已使用兜底解读。"), nil
|
||||
}
|
||||
return TableAnalysisResult{
|
||||
Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts,
|
||||
Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fallbackTableAnalysis(input TableAnalysisInput, usage Usage, reason string) TableAnalysisResult {
|
||||
title := firstNonEmpty(input.Topic, input.FileName, "智能表格解读报告")
|
||||
preview := fallbackTablePreview(input.Profile)
|
||||
metrics := fallbackTableMetrics(input, preview)
|
||||
alerts := []TableAlert{{
|
||||
Level: "warning",
|
||||
Title: "已启用兜底解读",
|
||||
Description: reason,
|
||||
}}
|
||||
if !input.Profile.Parsed {
|
||||
alerts = append(alerts, TableAlert{
|
||||
Level: "warning",
|
||||
Title: "表格内容未解析",
|
||||
Description: firstNonEmpty(input.Profile.Message, "当前无法读取表格内容,仅基于文件元信息生成解读。"),
|
||||
})
|
||||
}
|
||||
interpretation := fallbackTableInterpretation(input, preview, reason)
|
||||
return TableAnalysisResult{
|
||||
Title: title,
|
||||
Summary: excerptPlain(interpretation, 120),
|
||||
Metrics: metrics,
|
||||
Preview: preview,
|
||||
Alerts: alerts,
|
||||
Interpretation: interpretation,
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackTablePreview(profile TableProfile) TablePreview {
|
||||
var columns []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = decodeProfileAny(profile.Columns, &columns)
|
||||
names := make([]string, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
if name := strings.TrimSpace(column.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
var rows [][]string
|
||||
_ = decodeProfileAny(profile.SampleRows, &rows)
|
||||
if len(rows) > 5 {
|
||||
rows = rows[:5]
|
||||
}
|
||||
return TablePreview{Columns: names, Rows: rows, TotalRows: profile.RowCount}
|
||||
}
|
||||
|
||||
func fallbackTableMetrics(input TableAnalysisInput, preview TablePreview) []TableMetric {
|
||||
metrics := []TableMetric{
|
||||
{Name: "文件类型", Value: strings.ToUpper(input.FileType), ChangeText: "来自上传元信息", Trend: "flat"},
|
||||
{Name: "文件大小", Value: fmt.Sprintf("%d bytes", input.FileSize), ChangeText: "来自上传元信息", Trend: "flat"},
|
||||
}
|
||||
if input.Profile.Parsed {
|
||||
metrics = append(metrics,
|
||||
TableMetric{Name: "数据行数", Value: fmt.Sprintf("%d", preview.TotalRows), ChangeText: "来自表格解析", Trend: "flat"},
|
||||
TableMetric{Name: "字段数", Value: fmt.Sprintf("%d", len(preview.Columns)), ChangeText: "来自表格解析", Trend: "flat"},
|
||||
)
|
||||
} else {
|
||||
metrics = append(metrics, TableMetric{Name: "解析状态", Value: "未解析", ChangeText: firstNonEmpty(input.Profile.Message, "source file is not available locally"), Trend: "flat"})
|
||||
}
|
||||
var stats map[string]struct {
|
||||
Count int `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
Avg float64 `json:"avg"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
}
|
||||
if err := decodeProfileAny(input.Profile.Stats, &stats); err == nil {
|
||||
for name, stat := range stats {
|
||||
if len(metrics) >= 6 {
|
||||
break
|
||||
}
|
||||
metrics = append(metrics, TableMetric{
|
||||
Name: name,
|
||||
Value: fmt.Sprintf("平均 %.2f", stat.Avg),
|
||||
ChangeText: fmt.Sprintf("样本 %d,范围 %.2f-%.2f", stat.Count, stat.Min, stat.Max),
|
||||
Trend: "flat",
|
||||
})
|
||||
}
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func fallbackTableInterpretation(input TableAnalysisInput, preview TablePreview, reason string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(reason)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("文件《")
|
||||
b.WriteString(firstNonEmpty(input.FileName, input.FileID, "未命名文件"))
|
||||
b.WriteString("》已进入智能表格解读流程。")
|
||||
if input.Profile.Parsed {
|
||||
b.WriteString(fmt.Sprintf("当前已解析到 %d 行数据、%d 个字段。", preview.TotalRows, len(preview.Columns)))
|
||||
if len(preview.Columns) > 0 {
|
||||
b.WriteString("字段包括:")
|
||||
b.WriteString(strings.Join(preview.Columns, "、"))
|
||||
b.WriteString("。")
|
||||
}
|
||||
b.WriteString("建议结合字段含义继续查看数值分布、异常值和业务口径。")
|
||||
} else {
|
||||
b.WriteString("当前未能读取表格内容,报告仅基于文件名、类型和大小生成。")
|
||||
if msg := strings.TrimSpace(input.Profile.Message); msg != "" {
|
||||
b.WriteString("未解析原因:")
|
||||
b.WriteString(msg)
|
||||
b.WriteString("。")
|
||||
}
|
||||
b.WriteString("建议确认文件已上传完成、格式正确后重新发起解读。")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func decodeProfileAny(value any, target any) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
func excerptPlain(value string, maxRunes int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
|
||||
func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return MeetingSummaryResult{}, ErrNotConfigured
|
||||
}
|
||||
system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。"
|
||||
payload, _ := json.Marshal(input)
|
||||
user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。
|
||||
|
||||
要求:
|
||||
1. 不编造会议中没有出现的事实。
|
||||
2. 若附件内容不足,请基于标题和上下文给出谨慎总结。
|
||||
3. action_items 的 status 固定为 pending。
|
||||
4. 只输出 JSON:
|
||||
{
|
||||
"conclusions": ["string"],
|
||||
"decisions": ["string"],
|
||||
"action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}]
|
||||
}
|
||||
|
||||
会议上下文:
|
||||
%s`, string(payload))
|
||||
resp, err := s.client.Generate(ctx, GenerateRequest{
|
||||
Feature: "meeting_summary",
|
||||
Model: s.client.ComplexModel(),
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
MaxTokens: 1800,
|
||||
})
|
||||
if err != nil {
|
||||
return MeetingSummaryResult{}, err
|
||||
}
|
||||
var out struct {
|
||||
Conclusions []string `json:"conclusions"`
|
||||
Decisions []string `json:"decisions"`
|
||||
ActionItems []MeetingActionItem `json:"action_items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil {
|
||||
return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||||
}
|
||||
if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 {
|
||||
return MeetingSummaryResult{}, ErrBadOutput
|
||||
}
|
||||
for i := range out.ActionItems {
|
||||
if strings.TrimSpace(out.ActionItems[i].Status) == "" {
|
||||
out.ActionItems[i].Status = "pending"
|
||||
}
|
||||
}
|
||||
return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil
|
||||
}
|
||||
|
||||
func usageFromResponse(resp GenerateResponse) Usage {
|
||||
return Usage{
|
||||
ProviderRequestID: resp.ProviderRequestID,
|
||||
Model: resp.Model,
|
||||
PromptTokens: resp.PromptTokens,
|
||||
CompletionTokens: resp.CompletionTokens,
|
||||
TotalTokens: resp.TotalTokens,
|
||||
LatencyMS: resp.LatencyMS,
|
||||
}
|
||||
}
|
||||
|
||||
func markdownFromSections(sections []DocumentSection) string {
|
||||
var b strings.Builder
|
||||
for _, section := range sections {
|
||||
heading := strings.TrimSpace(section.Heading)
|
||||
content := strings.TrimSpace(section.Content)
|
||||
if heading == "" && content == "" {
|
||||
continue
|
||||
}
|
||||
if heading != "" {
|
||||
b.WriteString("## ")
|
||||
b.WriteString(heading)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if content != "" {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func plainFromSections(sections []DocumentSection) string {
|
||||
parts := make([]string, 0, len(sections)*2)
|
||||
for _, section := range sections {
|
||||
if heading := strings.TrimSpace(section.Heading); heading != "" {
|
||||
parts = append(parts, heading)
|
||||
}
|
||||
if content := strings.TrimSpace(section.Content); content != "" {
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFallbackTableAnalysisUnparsedProfile(t *testing.T) {
|
||||
result := fallbackTableAnalysis(TableAnalysisInput{
|
||||
Topic: "销售分析",
|
||||
FileID: "file1",
|
||||
FileName: "sales.xlsx",
|
||||
FileType: "xlsx",
|
||||
FileSize: 5149,
|
||||
Profile: TableProfile{Parsed: false, Message: "source file is not available locally"},
|
||||
}, Usage{Model: "deepseek-flash"}, "AI 输出格式异常,已使用兜底解读。")
|
||||
if result.Title != "销售分析" {
|
||||
t.Fatalf("Title=%q, want 销售分析", result.Title)
|
||||
}
|
||||
if strings.TrimSpace(result.Interpretation) == "" {
|
||||
t.Fatal("Interpretation is empty")
|
||||
}
|
||||
if len(result.Alerts) < 2 {
|
||||
t.Fatalf("Alerts len=%d, want at least 2", len(result.Alerts))
|
||||
}
|
||||
if got := result.Metrics[len(result.Metrics)-1].Value; got != "未解析" {
|
||||
t.Fatalf("last metric value=%q, want 未解析", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackDocumentBuildsDisplayableContent(t *testing.T) {
|
||||
result := fallbackDocument(
|
||||
"生成一个竞争销售部门副科长职位的演讲稿",
|
||||
"竞争销售部门副科长职位演讲稿",
|
||||
"meeting_minutes",
|
||||
Usage{Model: "deepseek-flash"},
|
||||
"AI 输出格式异常,已使用兜底文档。",
|
||||
)
|
||||
if result.Title != "竞争销售部门副科长职位演讲稿" {
|
||||
t.Fatalf("Title=%q, want title hint", result.Title)
|
||||
}
|
||||
if strings.TrimSpace(result.ContentMarkdown) == "" {
|
||||
t.Fatal("ContentMarkdown is empty")
|
||||
}
|
||||
if !strings.Contains(result.ContentMarkdown, "AI 输出格式异常") {
|
||||
t.Fatalf("ContentMarkdown=%q, want fallback reason", result.ContentMarkdown)
|
||||
}
|
||||
if !strings.Contains(result.ContentPlain, "生成一个竞争销售部门副科长职位的演讲稿") {
|
||||
t.Fatalf("ContentPlain=%q, want source text", result.ContentPlain)
|
||||
}
|
||||
if len(result.Sections) != 3 {
|
||||
t.Fatalf("Sections len=%d, want 3", len(result.Sections))
|
||||
}
|
||||
}
|
||||
+23
-54
@@ -5,72 +5,47 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/ai"
|
||||
"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/events"
|
||||
"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/managerconfig"
|
||||
"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"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
store *store.Store
|
||||
redis *redis.Client
|
||||
events events.Publisher
|
||||
api *httpapi.API
|
||||
cancel context.CancelFunc
|
||||
store *store.Store
|
||||
redis *redis.Client
|
||||
api *httpapi.API
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
uidKey := managerconfig.ResolveUIDKey(context.Background(), cfg.Manager, cfg.Auth.UIDKey)
|
||||
if err := httpapi.SetUIDKey(uidKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.SetUIDKey(uidKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.AI = managerconfig.ResolveAIConfig(context.Background(), cfg.Manager, cfg.AI)
|
||||
st, err := store.Open(cfg.MySQL, cfg.CoreMySQL)
|
||||
st, err := store.Open(cfg.MySQL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var redisClient *redis.Client
|
||||
if !cfg.Auth.SkipSessionInDev {
|
||||
redisClient = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
publisher, err := events.NewKafkaPublisher(cfg.Kafka)
|
||||
if err != nil {
|
||||
if redisClient != nil {
|
||||
_ = redisClient.Close()
|
||||
}
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
_ = redisClient.Close()
|
||||
_ = st.Close()
|
||||
return nil, err
|
||||
}
|
||||
aiService := ai.NewService(ai.NewClient(cfg.AI))
|
||||
api := httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth, cfg.Manager, aiService)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
api.StartAIWorkers(ctx)
|
||||
return &App{
|
||||
store: st,
|
||||
redis: redisClient,
|
||||
events: publisher,
|
||||
api: api,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
authenticator := auth.New(cfg.Auth, redisClient)
|
||||
accountService := accounts.New(st, redisClient, cfg.Auth)
|
||||
portalService := portal.New(st)
|
||||
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)}, nil
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
@@ -82,15 +57,9 @@ func (a *App) Handler() http.Handler {
|
||||
}
|
||||
|
||||
func (a *App) Close() {
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
if a.redis != nil {
|
||||
_ = a.redis.Close()
|
||||
}
|
||||
if a.events != nil {
|
||||
_ = a.events.Close()
|
||||
}
|
||||
if a.store != nil {
|
||||
_ = a.store.Close()
|
||||
}
|
||||
|
||||
+92
-92
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -24,22 +23,18 @@ var (
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
TenantID int64
|
||||
TenantCode string
|
||||
UserID int64
|
||||
DeviceID string
|
||||
AuthLevel string
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
DeviceID string `json:"device_id"`
|
||||
AuthLevel string `json:"auth_level"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
MemberID int64 `json:"member_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
@@ -51,23 +46,20 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
||||
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||
}
|
||||
|
||||
func (a *Authenticator) DefaultTenantID() int64 {
|
||||
return a.cfg.DefaultTenantID
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||
if err := a.validateAPIKey(req); err != nil {
|
||||
if err := a.ValidateAPIKey(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.AllowDevHeaderAuth {
|
||||
if principal := principalFromDevHeaders(req); principal != nil {
|
||||
return principal, nil
|
||||
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
||||
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID,
|
||||
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
||||
}
|
||||
}
|
||||
if a.cfg.SkipSessionInDev {
|
||||
return &Principal{
|
||||
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||
UserID: parseInt64Header(req, "X-User-ID", 1),
|
||||
AuthLevel: "auth",
|
||||
}, nil
|
||||
}
|
||||
if a.redis == nil {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
@@ -79,50 +71,60 @@ func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.UserID <= 0 || session.TenantID <= 0 {
|
||||
if session.MemberID <= 0 || session.TenantID < 0 || session.State != "active" {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if state := strings.ToLower(strings.TrimSpace(session.State)); state != "" && state != "active" {
|
||||
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if session.ExpiresAt != "" {
|
||||
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
|
||||
if err == nil && time.Now().After(expiresAt) {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if err := a.validateMemberSession(req.Context(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headerTenant := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
||||
if a.cfg.RequiredTenantCode && headerTenant == "" {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if headerTenant != "" && session.TenantCode != "" && headerTenant != session.TenantCode {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return &Principal{
|
||||
TenantID: session.TenantID,
|
||||
TenantCode: firstNonEmpty(session.TenantCode, headerTenant),
|
||||
UserID: session.UserID,
|
||||
DeviceID: session.DeviceID,
|
||||
AuthLevel: session.AuthLevel,
|
||||
}, nil
|
||||
return &Principal{TenantID: session.TenantID, MemberID: session.MemberID, DeviceID: session.DeviceID}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) validateAPIKey(req *http.Request) error {
|
||||
func (a *Authenticator) Logout(req *http.Request) error {
|
||||
if _, err := a.Authenticate(req); err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := bearerToken(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.redis == nil {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
return a.redis.Del(req.Context(), key).Err()
|
||||
}
|
||||
|
||||
func (a *Authenticator) RevokeMemberSessions(ctx context.Context, tenantID, memberID int64) error {
|
||||
if a == nil || a.redis == nil {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
key := memberRevokedBeforeKey(tenantID, memberID)
|
||||
value := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
ttl := time.Duration(a.cfg.SessionTTLSeconds) * time.Second
|
||||
return a.redis.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
func (a *Authenticator) ValidateAPIKey(req *http.Request) error {
|
||||
if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey {
|
||||
return nil
|
||||
}
|
||||
got := strings.TrimSpace(req.Header.Get("X-MeetingIM-APIKey"))
|
||||
got := strings.TrimSpace(req.Header.Get("X-API-Key"))
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(req.URL.Query().Get("apikey"))
|
||||
}
|
||||
if got == "" {
|
||||
if a.cfg.RequiredAPIKey {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
return ErrForbidden
|
||||
}
|
||||
if len(a.cfg.APIKeys) == 0 {
|
||||
return nil
|
||||
return ErrForbidden
|
||||
}
|
||||
for _, key := range a.cfg.APIKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 {
|
||||
@@ -133,14 +135,12 @@ func (a *Authenticator) validateAPIKey(req *http.Request) error {
|
||||
}
|
||||
|
||||
func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) {
|
||||
secret := []byte(strings.TrimSpace(token))
|
||||
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
|
||||
secret = decoded
|
||||
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
|
||||
if err != nil {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
digest := sha256.Sum256(secret)
|
||||
key := fmt.Sprintf(a.cfg.SessionKeyPattern, hex.EncodeToString(digest[:]))
|
||||
data, err := a.redis.Get(ctx, key).Bytes()
|
||||
if err != nil && errors.Is(err, redis.Nil) {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if err != nil {
|
||||
@@ -153,46 +153,46 @@ func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Se
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) validateMemberSession(ctx context.Context, session *Session) error {
|
||||
revokedBefore, err := a.redis.Get(ctx, memberRevokedBeforeKey(session.TenantID, session.MemberID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issuedAt, issuedErr := time.Parse(time.RFC3339Nano, session.IssuedAt)
|
||||
revokedAt, revokedErr := time.Parse(time.RFC3339Nano, revokedBefore)
|
||||
if issuedErr != nil || revokedErr != nil || !issuedAt.After(revokedAt) {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
||||
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
}
|
||||
|
||||
func sessionKeyFromToken(pattern, token string) (string, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return "", ErrAuthRequired
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1), nil
|
||||
}
|
||||
|
||||
func bearerToken(req *http.Request) (string, error) {
|
||||
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||
token, ok := strings.CutPrefix(authz, "Bearer ")
|
||||
authorization := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||
token, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||
if !ok || strings.TrimSpace(token) == "" {
|
||||
return "", ErrAuthRequired
|
||||
}
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
|
||||
func principalFromDevHeaders(req *http.Request) *Principal {
|
||||
uid := parseInt64Header(req, "X-User-ID", 0)
|
||||
if uid <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &Principal{
|
||||
TenantID: parseInt64Header(req, "X-Tenant-ID", 1),
|
||||
TenantCode: firstNonEmpty(req.Header.Get("X-Tenant-Code"), "default"),
|
||||
UserID: uid,
|
||||
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID")),
|
||||
AuthLevel: "auth",
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64Header(req *http.Request, key string, fallback int64) int64 {
|
||||
func parseInt64Header(req *http.Request, key string) int64 {
|
||||
value := strings.TrimSpace(req.Header.Get(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionKeyFromToken(t *testing.T) {
|
||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench: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) {
|
||||
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-209
@@ -15,12 +15,8 @@ import (
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
MySQL MySQLConfig `json:"mysql"`
|
||||
CoreMySQL MySQLConfig `json:"core_mysql"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Kafka KafkaConfig `json:"kafka"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
Manager ManagerConfig `json:"manager"`
|
||||
AI AIConfig `json:"ai"`
|
||||
ReadTimeout time.Duration `json:"-"`
|
||||
WriteTimeout time.Duration `json:"-"`
|
||||
}
|
||||
@@ -32,65 +28,26 @@ type MySQLConfig struct {
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DB int `json:"db"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
}
|
||||
|
||||
type KafkaConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Brokers []string `json:"brokers"`
|
||||
Topic string `json:"topic"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SASL string `json:"sasl"`
|
||||
ClientID string `json:"client_id"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
Addr string `json:"addr"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DB int `json:"db"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
APIKeys []string `json:"api_keys"`
|
||||
SessionKeyPattern string `json:"session_key_pattern"`
|
||||
UIDKey string `json:"uid_key"`
|
||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||
RequiredTenantCode bool `json:"required_tenant_code"`
|
||||
RequiredAPIKey bool `json:"required_api_key"`
|
||||
SkipSessionInDev bool `json:"skip_session_in_dev"`
|
||||
TrustedInternalAddr string `json:"trusted_internal_addr"`
|
||||
InternalServiceName string `json:"internal_service_name"`
|
||||
InternalSecret string `json:"internal_secret"`
|
||||
}
|
||||
|
||||
type ManagerConfig struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
ServiceName string `json:"service_name"`
|
||||
Secret string `json:"secret"`
|
||||
UIDKeyGroup string `json:"uid_key_group"`
|
||||
UIDKeyKeys []string `json:"uid_key_keys"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
DeepSeek DeepSeekConfig `json:"deepseek"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
ComplexModel string `json:"complex_model"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
}
|
||||
|
||||
type DeepSeekConfig struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
APIKeys []string `json:"api_keys"`
|
||||
SessionKeyPattern string `json:"session_key_pattern"`
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_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("IM_WORKBENCH_CONFIG")); path != "" {
|
||||
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -110,41 +67,16 @@ func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Listen: ":6061",
|
||||
MySQL: MySQLConfig{
|
||||
DSN: "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local",
|
||||
DSN: "root:root@tcp(127.0.0.1:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MaxOpenConns: 15,
|
||||
MaxIdleConns: 5,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Addr: "127.0.0.1:6379",
|
||||
KeyPrefix: "im:",
|
||||
},
|
||||
Kafka: KafkaConfig{
|
||||
Topic: "im.workbench.event",
|
||||
ClientID: "im_workbench",
|
||||
},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "im:session:token:%s",
|
||||
UIDKey: "dkXxWLwqUNRfYmdXRzOkZw==",
|
||||
RequiredTenantCode: true,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
Manager: ManagerConfig{
|
||||
ServiceName: "im-workbench",
|
||||
UIDKeyGroup: "im",
|
||||
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
||||
TimeoutMS: 3000,
|
||||
},
|
||||
AI: AIConfig{
|
||||
Provider: "deepseek",
|
||||
DefaultModel: "deepseek-flash",
|
||||
ComplexModel: "deepseek-v4-pro",
|
||||
Temperature: 0.2,
|
||||
MaxOutputTokens: 4096,
|
||||
DeepSeek: DeepSeekConfig{
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
TimeoutMS: 60000,
|
||||
MaxRetries: 2,
|
||||
},
|
||||
SessionKeyPattern: "workbench:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
ReadTimeout: 8 * time.Second,
|
||||
WriteTimeout: 8 * time.Second,
|
||||
@@ -152,90 +84,24 @@ func defaultConfig() *Config {
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
setString(&cfg.Listen, "IM_WORKBENCH_LISTEN")
|
||||
setString(&cfg.MySQL.DSN, "IM_WORKBENCH_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.CoreMySQL.DSN, "IM_WORKBENCH_CORE_MYSQL_DSN")
|
||||
setInt(&cfg.CoreMySQL.MaxOpenConns, "IM_WORKBENCH_CORE_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.CoreMySQL.MaxIdleConns, "IM_WORKBENCH_CORE_MYSQL_MAX_IDLE_CONNS")
|
||||
if strings.TrimSpace(cfg.CoreMySQL.DSN) == "" {
|
||||
cfg.CoreMySQL.DSN = cfg.MySQL.DSN
|
||||
}
|
||||
if cfg.CoreMySQL.MaxOpenConns == 0 {
|
||||
cfg.CoreMySQL.MaxOpenConns = cfg.MySQL.MaxOpenConns
|
||||
}
|
||||
if cfg.CoreMySQL.MaxIdleConns == 0 {
|
||||
cfg.CoreMySQL.MaxIdleConns = cfg.MySQL.MaxIdleConns
|
||||
}
|
||||
setString(&cfg.Redis.Addr, "IM_WORKBENCH_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "IM_WORKBENCH_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "IM_WORKBENCH_REDIS_PASSWORD")
|
||||
setString(&cfg.Redis.KeyPrefix, "IM_WORKBENCH_REDIS_KEY_PREFIX")
|
||||
setString(&cfg.Kafka.Topic, "IM_WORKBENCH_KAFKA_TOPIC")
|
||||
setString(&cfg.Kafka.Username, "IM_WORKBENCH_KAFKA_USERNAME")
|
||||
setString(&cfg.Kafka.Password, "IM_WORKBENCH_KAFKA_PASSWORD")
|
||||
setString(&cfg.Kafka.SASL, "IM_WORKBENCH_KAFKA_SASL")
|
||||
setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN")
|
||||
setString(&cfg.Auth.UIDKey, "IM_WORKBENCH_UID_KEY")
|
||||
setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR")
|
||||
setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME")
|
||||
setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET")
|
||||
setString(&cfg.Manager.BaseURL, "IM_WORKBENCH_MANAGER_BASE_URL")
|
||||
setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME")
|
||||
setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET")
|
||||
setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP")
|
||||
setString(&cfg.AI.Provider, "IM_WORKBENCH_AI_PROVIDER")
|
||||
setString(&cfg.AI.DeepSeek.BaseURL, "DEEPSEEK_BASE_URL")
|
||||
setString(&cfg.AI.DeepSeek.APIKey, "DEEPSEEK_API_KEY")
|
||||
setString(&cfg.AI.DefaultModel, "DEEPSEEK_DEFAULT_MODEL")
|
||||
setString(&cfg.AI.ComplexModel, "DEEPSEEK_COMPLEX_MODEL")
|
||||
setInt(&cfg.AI.DeepSeek.TimeoutMS, "DEEPSEEK_TIMEOUT_MS")
|
||||
setInt(&cfg.AI.DeepSeek.MaxRetries, "DEEPSEEK_MAX_RETRIES")
|
||||
setInt(&cfg.AI.MaxOutputTokens, "DEEPSEEK_MAX_OUTPUT_TOKENS")
|
||||
setFloat(&cfg.AI.Temperature, "DEEPSEEK_TEMPERATURE")
|
||||
if strings.TrimSpace(cfg.Auth.InternalSecret) == "" {
|
||||
setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Auth.InternalSecret) == "" {
|
||||
setString(&cfg.Auth.InternalSecret, "IM_ADMIN_API_SECRET")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Manager.Secret) == "" {
|
||||
setString(&cfg.Manager.Secret, "IM_CONFIG_API_SECRET")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Manager.Secret) == "" {
|
||||
setString(&cfg.Manager.Secret, "IM_INTERNAL_API_SECRET")
|
||||
}
|
||||
if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" {
|
||||
cfg.Kafka.Brokers = splitCSV(brokers)
|
||||
}
|
||||
if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_API_KEYS")); keys != "" {
|
||||
setString(&cfg.Listen, "WORKBENCH_LISTEN")
|
||||
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 != "" {
|
||||
cfg.Auth.APIKeys = splitCSV(keys)
|
||||
}
|
||||
if keys := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_UID_KEY_KEYS")); keys != "" {
|
||||
cfg.Manager.UIDKeyKeys = splitCSV(keys)
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BATCH_SIZE")); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Kafka.BatchSize = parsed
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_MANAGER_TIMEOUT_MS")); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Manager.TimeoutMS = parsed
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("IM_WORKBENCH_REDIS_DB")); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Redis.DB = parsed
|
||||
}
|
||||
}
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "IM_WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredTenantCode, "IM_WORKBENCH_REQUIRED_TENANT_CODE")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "IM_WORKBENCH_REQUIRED_API_KEY")
|
||||
setBool(&cfg.Auth.SkipSessionInDev, "IM_WORKBENCH_SKIP_SESSION_IN_DEV")
|
||||
setBool(&cfg.Kafka.Enabled, "IM_WORKBENCH_KAFKA_ENABLED")
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
@@ -245,47 +111,24 @@ func (c *Config) validate() error {
|
||||
if strings.TrimSpace(c.MySQL.DSN) == "" {
|
||||
return errors.New("mysql dsn is required")
|
||||
}
|
||||
mysqlDSN := strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://")
|
||||
parsedDSN, err := mysqlDriver.ParseDSN(mysqlDSN)
|
||||
parsed, err := mysqlDriver.ParseDSN(strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mysql dsn is invalid: %w", err)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") {
|
||||
return errors.New("mysql database tinode is deprecated; use workbench")
|
||||
if !strings.EqualFold(strings.TrimSpace(parsed.DBName), "olons_admin") {
|
||||
return errors.New("mysql database must be olons_admin")
|
||||
}
|
||||
coreMySQLDSN := strings.TrimPrefix(strings.TrimSpace(c.CoreMySQL.DSN), "mysql://")
|
||||
if _, err := mysqlDriver.ParseDSN(coreMySQLDSN); err != nil {
|
||||
return fmt.Errorf("core mysql dsn is invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev {
|
||||
if strings.TrimSpace(c.Redis.Addr) == "" {
|
||||
return errors.New("redis addr is required")
|
||||
}
|
||||
if strings.TrimSpace(c.Auth.SessionKeyPattern) == "" {
|
||||
return errors.New("session key pattern is required")
|
||||
}
|
||||
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
|
||||
return fmt.Errorf("session key pattern must include %%s")
|
||||
return errors.New("session key pattern must include %s")
|
||||
}
|
||||
if strings.TrimSpace(c.AI.Provider) == "" {
|
||||
c.AI.Provider = "deepseek"
|
||||
if c.Auth.SessionTTLSeconds <= 0 {
|
||||
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
||||
}
|
||||
if strings.TrimSpace(c.AI.DeepSeek.BaseURL) == "" {
|
||||
c.AI.DeepSeek.BaseURL = "https://api.deepseek.com"
|
||||
}
|
||||
if c.AI.DeepSeek.TimeoutMS <= 0 {
|
||||
c.AI.DeepSeek.TimeoutMS = 60000
|
||||
}
|
||||
if strings.TrimSpace(c.AI.DefaultModel) == "" {
|
||||
c.AI.DefaultModel = "deepseek-flash"
|
||||
}
|
||||
if strings.TrimSpace(c.AI.ComplexModel) == "" {
|
||||
c.AI.ComplexModel = c.AI.DefaultModel
|
||||
}
|
||||
if c.AI.Temperature == 0 {
|
||||
c.AI.Temperature = 0.2
|
||||
}
|
||||
if c.AI.MaxOutputTokens <= 0 {
|
||||
c.AI.MaxOutputTokens = 4096
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -304,6 +147,14 @@ func setInt(target *int, key string) {
|
||||
}
|
||||
}
|
||||
|
||||
func setInt64(target *int64, key string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setBool(target *bool, key string) {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
@@ -313,14 +164,6 @@ func setBool(target *bool, key string) {
|
||||
}
|
||||
}
|
||||
|
||||
func setFloat(target *float64, key string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]string, 0, len(parts))
|
||||
|
||||
@@ -1,100 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true"
|
||||
|
||||
err := cfg.validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") {
|
||||
t.Fatalf("validate() error = %v, want deprecated database error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigUsesWorkbenchDatabase(t *testing.T) {
|
||||
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
if err := cfg.validate(); err != nil {
|
||||
t.Fatalf("default config validation failed: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cfg.MySQL.DSN, "/workbench?") {
|
||||
t.Fatalf("default mysql dsn = %q, want workbench database", cfg.MySQL.DSN)
|
||||
}
|
||||
if cfg.MySQL.MaxOpenConns != 15 || cfg.MySQL.MaxIdleConns != 5 {
|
||||
t.Fatalf("default mysql pool = %d/%d, want 15/5", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
|
||||
if cfg.Auth.DefaultTenantID != 0 {
|
||||
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverridesMySQLPool(t *testing.T) {
|
||||
t.Setenv("IM_WORKBENCH_MYSQL_MAX_OPEN_CONNS", "12")
|
||||
t.Setenv("IM_WORKBENCH_MYSQL_MAX_IDLE_CONNS", "4")
|
||||
func TestConfigRejectsOtherDatabase(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
applyEnv(cfg)
|
||||
|
||||
if cfg.MySQL.MaxOpenConns != 12 || cfg.MySQL.MaxIdleConns != 4 {
|
||||
t.Fatalf("mysql pool = %d/%d, want 12/4", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFallsBackCoreMySQLToPrimaryDSN(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workbench.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("IM_WORKBENCH_CONFIG", path)
|
||||
t.Setenv("IM_WORKBENCH_MYSQL_DSN", "")
|
||||
t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.CoreMySQL.DSN != cfg.MySQL.DSN {
|
||||
t.Fatalf("core_mysql.dsn = %q, want primary mysql.dsn %q", cfg.CoreMySQL.DSN, cfg.MySQL.DSN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCoreMySQLDSNFromEnvironment(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workbench.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("IM_WORKBENCH_CONFIG", path)
|
||||
t.Setenv("IM_WORKBENCH_MYSQL_DSN", "")
|
||||
t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cfg.CoreMySQL.DSN, "/meeting_im?") {
|
||||
t.Fatalf("core_mysql.dsn = %q, want meeting_im", cfg.CoreMySQL.DSN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesCoreMySQLDSNFromJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workbench.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true"},"core_mysql":{"dsn":"root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("IM_WORKBENCH_CONFIG", path)
|
||||
t.Setenv("IM_WORKBENCH_MYSQL_DSN", "")
|
||||
t.Setenv("IM_WORKBENCH_CORE_MYSQL_DSN", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cfg.MySQL.DSN, "/workbench?") {
|
||||
t.Fatalf("mysql.dsn = %q, want workbench", cfg.MySQL.DSN)
|
||||
}
|
||||
if !strings.Contains(cfg.CoreMySQL.DSN, "/meeting_im?") {
|
||||
t.Fatalf("core_mysql.dsn = %q, want meeting_im", cfg.CoreMySQL.DSN)
|
||||
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/other_database?parseTime=true"
|
||||
if err := cfg.validate(); err == nil {
|
||||
t.Fatal("expected non-olons_admin database to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"github.com/segmentio/kafka-go"
|
||||
"github.com/segmentio/kafka-go/sasl"
|
||||
"github.com/segmentio/kafka-go/sasl/scram"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
EventID string `json:"event_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
ActorUserID int64 `json:"actor_user_id"`
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
TargetUserIDs []int64 `json:"target_user_ids,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Publisher interface {
|
||||
Publish(context.Context, Event) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type NoopPublisher struct{}
|
||||
|
||||
func (NoopPublisher) Publish(context.Context, Event) error { return nil }
|
||||
func (NoopPublisher) Close() error { return nil }
|
||||
|
||||
type KafkaPublisher struct {
|
||||
writer *kafka.Writer
|
||||
topic string
|
||||
}
|
||||
|
||||
func NewKafkaPublisher(cfg config.KafkaConfig) (Publisher, error) {
|
||||
if !cfg.Enabled || len(cfg.Brokers) == 0 {
|
||||
return NoopPublisher{}, nil
|
||||
}
|
||||
topic := strings.TrimSpace(cfg.Topic)
|
||||
if topic == "" {
|
||||
topic = "im.workbench.event"
|
||||
}
|
||||
transport := &kafka.Transport{}
|
||||
if mechanism, err := saslMechanism(cfg); err != nil {
|
||||
return nil, err
|
||||
} else if mechanism != nil {
|
||||
transport.SASL = mechanism
|
||||
}
|
||||
writer := &kafka.Writer{
|
||||
Addr: kafka.TCP(cfg.Brokers...),
|
||||
Topic: topic,
|
||||
Balancer: &kafka.Hash{},
|
||||
Transport: transport,
|
||||
BatchSize: cfg.BatchSize,
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
Async: false,
|
||||
}
|
||||
if writer.BatchSize <= 0 {
|
||||
writer.BatchSize = 1
|
||||
}
|
||||
return &KafkaPublisher{writer: writer, topic: topic}, nil
|
||||
}
|
||||
|
||||
func (p *KafkaPublisher) Publish(ctx context.Context, event Event) error {
|
||||
if p == nil || p.writer == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(event.EventID) == "" {
|
||||
event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID)
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now().UTC().Round(time.Millisecond)
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := []byte(event.TenantCode + ":" + event.ResourceID)
|
||||
if len(key) == 1 {
|
||||
key = []byte(event.EventID)
|
||||
}
|
||||
return p.writer.WriteMessages(ctx, kafka.Message{
|
||||
Key: key,
|
||||
Value: data,
|
||||
Headers: []kafka.Header{
|
||||
{Key: "event_id", Value: []byte(event.EventID)},
|
||||
{Key: "event_type", Value: []byte(event.Action)},
|
||||
{Key: "tenant_code", Value: []byte(event.TenantCode)},
|
||||
},
|
||||
Time: event.OccurredAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *KafkaPublisher) Close() error {
|
||||
if p == nil || p.writer == nil {
|
||||
return nil
|
||||
}
|
||||
return p.writer.Close()
|
||||
}
|
||||
|
||||
func NewWorkbenchEvent(p *auth.Principal, traceID, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) Event {
|
||||
event := Event{
|
||||
TraceID: strings.TrimSpace(traceID),
|
||||
TenantID: p.TenantID,
|
||||
TenantCode: p.TenantCode,
|
||||
ActorUserID: p.UserID,
|
||||
Module: strings.TrimSpace(module),
|
||||
Action: strings.TrimSpace(action),
|
||||
ResourceID: strings.TrimSpace(resourceID),
|
||||
TargetUserIDs: compactInt64s(targets),
|
||||
Title: strings.TrimSpace(title),
|
||||
Summary: strings.TrimSpace(summary),
|
||||
OccurredAt: time.Now().UTC().Round(time.Millisecond),
|
||||
Data: data,
|
||||
}
|
||||
event.EventID = StableEventID(event.TenantID, event.Module, event.Action, event.ResourceID, event.ActorUserID)
|
||||
return event
|
||||
}
|
||||
|
||||
func StableEventID(tenantID int64, module, action, resourceID string, actorUserID int64) string {
|
||||
raw := strings.Join([]string{
|
||||
int64String(tenantID),
|
||||
strings.TrimSpace(module),
|
||||
strings.TrimSpace(action),
|
||||
strings.TrimSpace(resourceID),
|
||||
int64String(actorUserID),
|
||||
}, ":")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return "wbe_" + hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
func saslMechanism(cfg config.KafkaConfig) (sasl.Mechanism, error) {
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(cfg.SASL)) {
|
||||
case "", "SCRAM-SHA-512":
|
||||
return scram.Mechanism(scram.SHA512, cfg.Username, cfg.Password)
|
||||
case "SCRAM-SHA-256":
|
||||
return scram.Mechanism(scram.SHA256, cfg.Username, cfg.Password)
|
||||
default:
|
||||
return nil, errors.New("unsupported kafka sasl mechanism")
|
||||
}
|
||||
}
|
||||
|
||||
func compactInt64s(values []int64) []int64 {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]bool, len(values))
|
||||
items := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
items = append(items, value)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func int64String(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package finance
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("finance service unavailable")
|
||||
ErrAccountTypeInvalid = errors.New("payment account type invalid")
|
||||
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
|
||||
ErrTradePassword = errors.New("trade password invalid")
|
||||
ErrTradePasswordUnset = errors.New("trade password is not set")
|
||||
ErrRateLimited = errors.New("trade password attempts rate limited")
|
||||
ErrCurrencyInvalid = errors.New("currency invalid")
|
||||
ErrAmountInvalid = errors.New("amount invalid")
|
||||
ErrIdempotencyKey = errors.New("idempotency key invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
ErrStatusInvalid = errors.New("withdrawal status invalid")
|
||||
ErrOrderIDInvalid = errors.New("withdrawal order id invalid")
|
||||
)
|
||||
@@ -0,0 +1,737 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
quoteTTL = 5 * time.Minute
|
||||
passwordMaxTry = 5
|
||||
)
|
||||
|
||||
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}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
||||
PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error)
|
||||
CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error)
|
||||
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
|
||||
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
|
||||
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
|
||||
CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error)
|
||||
WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error)
|
||||
WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
aead cipher.AEAD
|
||||
lookupKey []byte
|
||||
ready bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type QuoteInput struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
AccountMask string `json:"account_mask"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
RequestAmount int64 `json:"request_amount"`
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
ActualAmount int64 `json:"actual_amount"`
|
||||
Currency string `json:"currency"`
|
||||
MinimumAmount int64 `json:"minimum_amount"`
|
||||
MaximumAmount int64 `json:"maximum_amount"`
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
TodayCount int `json:"today_count"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
QuoteExpiresAt string `json:"quote_expires_at"`
|
||||
}
|
||||
|
||||
type CreateWithdrawalInput struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
TradePassword string `json:"trade_password"`
|
||||
}
|
||||
|
||||
type Withdrawal struct {
|
||||
ID int64 `json:"id"`
|
||||
WithdrawalNo string `json:"withdrawal_no"`
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
RequestAmount int64 `json:"request_amount"`
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
ActualAmount int64 `json:"actual_amount"`
|
||||
Currency string `json:"currency"`
|
||||
AccountMask string `json:"account_mask"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
ReasonText string `json:"reason_text"`
|
||||
ExternalNo string `json:"external_no,omitempty"`
|
||||
RiskResult json.RawMessage `json:"risk_result,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ProcessedAt string `json:"processed_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IdempotentReplay bool `json:"idempotent_replay,omitempty"`
|
||||
}
|
||||
|
||||
type WithdrawalPage struct {
|
||||
Items []Withdrawal `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
service.aead = aead
|
||||
service.lookupKey = lookupKey
|
||||
service.ready = repository != nil && redisClient != nil
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PaymentAccount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, paymentAccountFromStore(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
holderCipher, err := s.encrypt(input.HolderName)
|
||||
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,
|
||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||
QRAttachmentID: input.QRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := paymentAccountFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quote(ctx, tenantID, memberID, input)
|
||||
}
|
||||
|
||||
func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64, idempotencyKey string, input CreateWithdrawalInput) (*Withdrawal, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if !idempotencyPattern.MatchString(idempotencyKey) {
|
||||
return nil, ErrIdempotencyKey
|
||||
}
|
||||
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quote, account, channel, err := s.quoteData(ctx, tenantID, memberID, QuoteInput{
|
||||
PaymentAccountID: input.PaymentAccountID, Amount: input.Amount, Currency: input.Currency})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holderName, err := s.decrypt(account.HolderCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
accountNo, err := s.decrypt(account.AccountNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
reservedPhone := ""
|
||||
if account.MobileCipher != "" {
|
||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshotCipher, err := s.encrypt(string(snapshot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withdrawalNo := deterministicNumber("WD", tenantID, memberID, idempotencyKey)
|
||||
row, err := s.repository.CreateWithdrawal(ctx, store.WithdrawalCreation{
|
||||
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
|
||||
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,
|
||||
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
||||
BankName: account.BankName, HolderMask: account.HolderMask,
|
||||
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
|
||||
FrozenLedgerNo: deterministicNumber("LF", tenantID, memberID, idempotencyKey),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := withdrawalFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
statusCode, ok := withdrawalStatusCode(status)
|
||||
if !ok {
|
||||
return nil, ErrStatusInvalid
|
||||
}
|
||||
rows, err := s.repository.WithdrawalOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Withdrawal, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, withdrawalFromStore(row))
|
||||
}
|
||||
return &WithdrawalPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
row, err := s.repository.WithdrawalOrder(ctx, tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := withdrawalFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
||||
return quote, err
|
||||
}
|
||||
|
||||
func (s *Service) quoteData(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, *store.PaymentAccount, *store.WithdrawalChannel, error) {
|
||||
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
|
||||
if input.PaymentAccountID <= 0 {
|
||||
return nil, nil, nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
if input.Currency != "CNY" {
|
||||
return nil, nil, nil, ErrCurrencyInvalid
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
return nil, nil, nil, ErrAmountInvalid
|
||||
}
|
||||
account, err := s.repository.PaymentAccountForWithdrawal(ctx, tenantID, memberID, input.PaymentAccountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if account.VerifyStatus != 2 {
|
||||
return nil, nil, nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
channel, err := s.repository.ActiveWithdrawalChannel(ctx, tenantID, channelTypes(account.AccountType))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
wallet, err := s.repository.WithdrawalWallet(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if wallet.AccountStatus != 1 {
|
||||
return nil, nil, nil, store.ErrAccountRestricted
|
||||
}
|
||||
minimum := channel.MinAmount
|
||||
if minimum < 1 {
|
||||
minimum = 1
|
||||
}
|
||||
maximum := channel.MaxAmount
|
||||
if maximum == 0 || maximum > wallet.AvailableAmount {
|
||||
maximum = wallet.AvailableAmount
|
||||
}
|
||||
if input.Amount < minimum || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) {
|
||||
return nil, nil, nil, store.ErrWithdrawalAmount
|
||||
}
|
||||
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
|
||||
return nil, nil, nil, store.ErrWithdrawalLimit
|
||||
}
|
||||
if input.Amount > wallet.AvailableAmount {
|
||||
return nil, nil, nil, store.ErrInsufficientBalance
|
||||
}
|
||||
fee, err := withdrawalFee(input.Amount, *channel)
|
||||
if err != nil || fee < 0 || fee >= input.Amount {
|
||||
return nil, nil, nil, store.ErrWithdrawalAmount
|
||||
}
|
||||
return &Quote{PaymentAccountID: account.ID, AccountMask: account.AccountNoMask,
|
||||
ChannelID: channel.ID, ChannelCode: channel.Code, ChannelName: channel.Name,
|
||||
RequestAmount: input.Amount, FeeAmount: fee, ActualAmount: input.Amount - fee,
|
||||
Currency: input.Currency, MinimumAmount: minimum, MaximumAmount: maximum,
|
||||
AvailableAmount: wallet.AvailableAmount, TodayCount: wallet.TodayCount, DailyLimit: wallet.DailyLimit,
|
||||
QuoteExpiresAt: s.now().UTC().Add(quoteTTL).Format(time.RFC3339)}, account, channel, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) error {
|
||||
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") {
|
||||
return store.ErrWithdrawalDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID int64, password string) (*store.AccountSecurity, error) {
|
||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
key := fmt.Sprintf("workbench: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
|
||||
}
|
||||
if attempts >= passwordMaxTry {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if security.TradePasswordHash == "" {
|
||||
return nil, ErrTradePasswordUnset
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(password)) != nil {
|
||||
pipe := s.redis.TxPipeline()
|
||||
pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 15*time.Minute)
|
||||
if _, rateErr := pipe.Exec(ctx); rateErr != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
if err := s.redis.Del(ctx, key).Err(); err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal {
|
||||
risk := row.RiskResult
|
||||
if len(risk) == 0 {
|
||||
risk = json.RawMessage(`{}`)
|
||||
}
|
||||
return Withdrawal{ID: row.ID, WithdrawalNo: row.WithdrawalNo, PaymentAccountID: row.PaymentAccountID,
|
||||
ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount,
|
||||
FeeAmount: row.FeeAmount, ActualAmount: row.ActualAmount, Currency: row.Currency,
|
||||
AccountMask: row.AccountMask, BankName: row.BankName, HolderMask: row.HolderMask,
|
||||
Status: withdrawalStatusName(row.Status), StatusText: withdrawalStatusText(row.Status),
|
||||
ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, ExternalNo: row.ExternalNo,
|
||||
RiskResult: risk, ReviewedAt: row.ReviewedAt, ProcessedAt: row.ProcessedAt,
|
||||
CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
IdempotentReplay: row.Replayed}
|
||||
}
|
||||
|
||||
func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) {
|
||||
if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 {
|
||||
return 0, store.ErrWithdrawalChannel
|
||||
}
|
||||
rate := int64(channel.FeeRate)
|
||||
rateFee := (amount/10000)*rate + ((amount%10000)*rate+9999)/10000
|
||||
switch channel.FeeType {
|
||||
case 1:
|
||||
return channel.FeeFixedAmount, nil
|
||||
case 2:
|
||||
return rateFee, nil
|
||||
case 3:
|
||||
return channel.FeeFixedAmount + rateFee, nil
|
||||
default:
|
||||
return 0, store.ErrWithdrawalChannel
|
||||
}
|
||||
}
|
||||
|
||||
func channelTypes(accountType int) []int {
|
||||
specific := map[int]int{1: 3, 2: 4, 3: 3, 4: 2, 5: 1}[accountType]
|
||||
if specific == 0 || specific == 4 {
|
||||
return []int{4}
|
||||
}
|
||||
return []int{specific, 4}
|
||||
}
|
||||
|
||||
func accountTypeName(value int) string {
|
||||
names := map[int]string{1: "bank_card", 2: "digital_wallet", 3: "unionpay", 4: "alipay", 5: "wechat"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func verifyStatusName(value int) string {
|
||||
names := map[int]string{1: "pending", 2: "verified", 3: "rejected"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func withdrawalStatusCode(value string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return 0, true
|
||||
case "pending_review":
|
||||
return 1, true
|
||||
case "reviewing":
|
||||
return 2, true
|
||||
case "processing":
|
||||
return 3, true
|
||||
case "succeeded":
|
||||
return 4, true
|
||||
case "failed":
|
||||
return 5, true
|
||||
case "rejected":
|
||||
return 6, true
|
||||
case "cancelled":
|
||||
return 7, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawalStatusName(value int) string {
|
||||
names := map[int]string{1: "pending_review", 2: "reviewing", 3: "processing", 4: "succeeded", 5: "failed", 6: "rejected", 7: "cancelled"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func withdrawalStatusText(value int) string {
|
||||
names := map[int]string{1: "待审核", 2: "审核中", 3: "处理中", 4: "已成功", 5: "失败", 6: "已驳回", 7: "已取消"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func (s *Service) encrypt(value string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
||||
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||
}
|
||||
|
||||
func (s *Service) decrypt(value string) (string, error) {
|
||||
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||
return "", errors.New("encrypted value is invalid")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupHash(value string) string {
|
||||
mac := hmac.New(sha256.New, s.lookupKey)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func deterministicNumber(prefix string, tenantID, memberID int64, key string) string {
|
||||
digest := sha256.Sum256([]byte(strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) + ":" + key + ":" + prefix))
|
||||
return prefix + strings.ToUpper(hex.EncodeToString(digest[:16]))
|
||||
}
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func maskPhone(value string) string {
|
||||
if len(value) != 11 {
|
||||
return ""
|
||||
}
|
||||
return value[:3] + "****" + value[7:]
|
||||
}
|
||||
|
||||
func maskName(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(runes) == 1 {
|
||||
return string(runes[0]) + "*"
|
||||
}
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
|
||||
}
|
||||
|
||||
func maskAccount(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 4 {
|
||||
return strings.Repeat("*", len(runes))
|
||||
}
|
||||
if len(runes) <= 6 {
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1])
|
||||
}
|
||||
prefix := 4
|
||||
if len(runes) < 12 {
|
||||
prefix = 2
|
||||
}
|
||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func validHumanName(value string) bool {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) || r == '<' || r == '>' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
account *store.PaymentAccount
|
||||
channel *store.WithdrawalChannel
|
||||
wallet *store.WithdrawalWallet
|
||||
orders []store.WithdrawalOrder
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return f.config, nil
|
||||
}
|
||||
func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
|
||||
return f.account, nil
|
||||
}
|
||||
func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) {
|
||||
return f.channel, nil
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) {
|
||||
return f.wallet, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) {
|
||||
return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
||||
account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2},
|
||||
channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3,
|
||||
MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25},
|
||||
wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}}
|
||||
service := &Service{repository: repository, ready: true, now: func() time.Time {
|
||||
return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
|
||||
result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 ||
|
||||
result.QuoteExpiresAt != "2026-10-09T02:05:00Z" {
|
||||
t.Fatalf("unexpected quote: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFailsClosed(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
|
||||
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
|
||||
if !errors.Is(err, store.ErrWithdrawalDisabled) {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
|
||||
if got := maskAccount("abcde"); got != "a***e" {
|
||||
t.Fatalf("unexpected mask %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalStatusValidation(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now}
|
||||
_, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad")
|
||||
if !errors.Is(err, ErrStatusInvalid) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
|
||||
first := deterministicNumber("WD", 0, 1, "request-key")
|
||||
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
||||
t.Fatal("deterministic number is not stable or member scoped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptionRoundTrip(t *testing.T) {
|
||||
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
||||
DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=",
|
||||
})
|
||||
ciphertext, err := service.encrypt("6222000000001234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := service.decrypt(ciphertext)
|
||||
if err != nil || plain != "6222000000001234" {
|
||||
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
ReferralCode string `json:"referral_code,omitempty"`
|
||||
PrivacyAccepted bool `json:"privacy_accepted"`
|
||||
}
|
||||
|
||||
type accountLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) accountRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountRegisterRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Register(r.Context(), accountsvc.RegisterInput{
|
||||
Username: body.Username, Phone: body.Phone, Password: body.Password,
|
||||
ReferralCode: body.ReferralCode, PrivacyAccepted: body.PrivacyAccepted,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountLoginRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Login(r.Context(), accountsvc.LoginInput{
|
||||
Phone: body.Phone, Password: body.Password,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
if err := a.auth.Logout(r); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrAuthRequired):
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
case errors.Is(err, auth.ErrForbidden):
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) validatePublicClient(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.accounts == nil || a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
if err := a.auth.ValidateAPIKey(r); err != nil {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "valid API key required", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accountResultPayload(result *accountsvc.Result) map[string]any {
|
||||
return map[string]any{
|
||||
"member_id": result.Account.MemberID,
|
||||
"member_no": result.Account.MemberNo,
|
||||
"username": result.Account.Username,
|
||||
"phone_mask": result.Account.MobileMask,
|
||||
"invite_code": result.Account.InviteCode,
|
||||
"token": result.Token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": result.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrUsernameInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "USERNAME_INVALID_FORMAT", "validation failed", map[string]any{"field": "username"})
|
||||
case errors.Is(err, accountsvc.ErrPhoneInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PHONE_INVALID_FORMAT", "validation failed", map[string]any{"field": "phone"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PASSWORD_INVALID_FORMAT", "validation failed", map[string]any{"field": "password"})
|
||||
case errors.Is(err, accountsvc.ErrReferralInvalid), errors.Is(err, store.ErrReferralInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "REFERRAL_CODE_INVALID", "validation failed", map[string]any{"field": "referral_code"})
|
||||
case errors.Is(err, accountsvc.ErrPrivacyNotAccepted):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRIVACY_POLICY_NOT_ACCEPTED", "validation failed", map[string]any{"field": "privacy_accepted"})
|
||||
case errors.Is(err, store.ErrAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_ALREADY_REGISTERED", "account already registered", nil)
|
||||
case errors.Is(err, store.ErrInvalidCredentials):
|
||||
writeError(w, r, http.StatusUnauthorized, 30043, "INVALID_CREDENTIALS", "invalid phone or password", nil)
|
||||
case errors.Is(err, accountsvc.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many login attempts", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.TenantID <= 0 {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"})
|
||||
return
|
||||
}
|
||||
st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := st.InitializeTenantDefaults(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{
|
||||
"tenant_id": body.TenantID,
|
||||
"workbench_apps": result.WorkbenchApps,
|
||||
"attendance_rules": result.AttendanceRules,
|
||||
"attendance_shifts": result.AttendanceShifts,
|
||||
"mailbox_configs": result.MailboxConfigs,
|
||||
"feedback_configs": result.FeedbackConfigs,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
var enabled *int
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
enabled = &value
|
||||
}
|
||||
}
|
||||
items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
case http.MethodPost:
|
||||
var body adminWorkbenchModuleRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{
|
||||
ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route,
|
||||
SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminWorkbenchModulePayload(*item))
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items))
|
||||
for _, item := range body.Items {
|
||||
items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder})
|
||||
}
|
||||
if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64)
|
||||
items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")),
|
||||
r.URL.Query().Get("employment_state"), userID)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminMailboxConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminMailboxConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminMailboxConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{
|
||||
Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields,
|
||||
MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminFeedbackConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminFeedbackConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminFeedbackConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{
|
||||
Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy,
|
||||
AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) {
|
||||
tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64)
|
||||
tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default")
|
||||
st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return nil, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
type adminWorkbenchModuleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconFileID string `json:"icon_file_id"`
|
||||
Route string `json:"route"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
UnreadKey string `json:"unread_key"`
|
||||
Enabled int `json:"enabled"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type adminMailboxConfigRequest struct {
|
||||
Enabled int `json:"enabled"`
|
||||
AllowAnonymous int `json:"allow_anonymous"`
|
||||
RequiredFields string `json:"required_fields"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
NoticeTitle string `json:"notice_title"`
|
||||
NoticeContent string `json:"notice_content"`
|
||||
}
|
||||
|
||||
type adminFeedbackConfigRequest struct {
|
||||
Categories string `json:"categories"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
AllowContact int `json:"allow_contact"`
|
||||
DiagnosticsNotice string `json:"diagnostics_notice"`
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, adminWorkbenchModulePayload(item))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any {
|
||||
return map[string]any{
|
||||
"id": item.ID,
|
||||
"code": item.Code,
|
||||
"name": item.Name,
|
||||
"iconFileId": item.IconFileID,
|
||||
"route": item.Route,
|
||||
"sortOrder": item.SortOrder,
|
||||
"unreadKey": item.UnreadKey,
|
||||
"enabled": item.Enabled,
|
||||
"permission": item.Permission,
|
||||
"createdAt": formatAdminTime(item.CreatedAt),
|
||||
"updatedAt": formatAdminTime(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"department_id": item.DepartmentID,
|
||||
"name": item.Name,
|
||||
"parent_id": item.ParentID,
|
||||
"child_count": item.ChildCount,
|
||||
"member_count": item.MemberCount,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"department_id": item.DepartmentID,
|
||||
"display_name": item.DisplayName,
|
||||
"position": item.Position,
|
||||
"mobile": item.Mobile,
|
||||
"email": item.Email,
|
||||
"avatar_file_id": item.AvatarFileID,
|
||||
"status": item.Status,
|
||||
"contactable": item.Contactable,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"enabled": cfg.Enabled,
|
||||
"allowAnonymous": cfg.AllowAnonymous,
|
||||
"requiredFields": cfg.RequiredFields,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"noticeTitle": cfg.NoticeTitle,
|
||||
"noticeContent": cfg.NoticeContent,
|
||||
"noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt),
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"categories": cfg.Categories,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"allowContact": cfg.AllowContact,
|
||||
"diagnosticsNotice": cfg.DiagnosticsNotice,
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatAdminTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,254 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/managerconfig"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
)
|
||||
|
||||
const aiTableExportDocxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
type aiExportedFile struct {
|
||||
FileID string
|
||||
FileName string
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type ossUploadConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
ServeURL string
|
||||
CacheControl string
|
||||
}
|
||||
|
||||
func (a *API) uploadAITableAnalysisExport(ctx context.Context, r *http.Request, st *store.Store, p *auth.Principal, item *store.AITableAnalysis) (*aiExportedFile, error) {
|
||||
cfg, err := a.ossUploadConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := aiTableAnalysisDocx(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileID := newFileUploadID()
|
||||
publicID, storageID := formatFileUploadID(fileID)
|
||||
createdAt := time.Now().UTC()
|
||||
objectKey := path.Join("tenants", strconv.FormatInt(p.TenantID, 10), createdAt.Format("2006"), createdAt.Format("01"), storageID)
|
||||
client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := []aliyunoss.Option{aliyunoss.ContentType(aiTableExportDocxMIME)}
|
||||
if cfg.CacheControl != "" {
|
||||
options = append(options, aliyunoss.CacheControl(cfg.CacheControl))
|
||||
}
|
||||
if err := bucket.PutObject(objectKey, bytes.NewReader(data), options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag := ""
|
||||
if meta, err := bucket.GetObjectDetailedMeta(objectKey); err == nil {
|
||||
etag = strings.Trim(meta.Get("ETag"), `"`)
|
||||
}
|
||||
fileName := aiTableExportFileName(item)
|
||||
fileURLName := publicID + ".docx"
|
||||
if err := st.SaveCompletedFileUpload(ctx, store.CompletedFileUpload{
|
||||
ID: fileID, UserID: p.UserID, MIMEType: aiTableExportDocxMIME, Size: int64(len(data)),
|
||||
ETag: etag, Location: objectKey, CreatedAt: createdAt,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.UpdateAITableAnalysisExportFileID(ctx, p.UserID, item.RecordID, fileURLName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aiExportedFile{
|
||||
FileID: fileURLName,
|
||||
FileName: fileName,
|
||||
DownloadURL: publicFileDownloadURL(r, cfg.ServeURL, fileURLName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadWorkbenchFileToTemp(r *http.Request, fileID, fileType string) (string, func(), error) {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return "", func() {}, fmt.Errorf("empty file id")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, absoluteURL(r, "/v0/file/s/"+url.PathEscape(fileID)), nil)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
copyHeader(req, r, "Authorization")
|
||||
copyHeader(req, r, "X-MeetingIM-APIKey")
|
||||
copyHeader(req, r, "X-Tenant-Code")
|
||||
copyHeader(req, r, "X-IM-Schema")
|
||||
copyHeader(req, r, "X-Request-ID")
|
||||
if auth := strings.TrimSpace(r.Header.Get("X-MeetingIM-Auth")); auth != "" {
|
||||
req.Header.Set("X-MeetingIM-Auth", auth)
|
||||
} else if token := bearerTokenFromHeader(r.Header.Get("Authorization")); token != "" {
|
||||
req.Header.Set("X-MeetingIM-Auth", "token "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", func() {}, fmt.Errorf("file service returned %d", resp.StatusCode)
|
||||
}
|
||||
return responseBodyToTemp(resp.Body, fileType)
|
||||
}
|
||||
|
||||
func (a *API) downloadOSSObjectToTemp(ctx context.Context, objectKey, fileType string) (string, func(), error) {
|
||||
objectKey = strings.Trim(strings.TrimSpace(objectKey), "/")
|
||||
if objectKey == "" {
|
||||
return "", func() {}, fmt.Errorf("empty object key")
|
||||
}
|
||||
cfg, err := a.ossUploadConfig(ctx)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
reader, err := bucket.GetObject(objectKey)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
return responseBodyToTemp(reader, fileType)
|
||||
}
|
||||
|
||||
func responseBodyToTemp(reader io.Reader, fileType string) (string, func(), error) {
|
||||
suffix := "." + strings.TrimLeft(strings.ToLower(strings.TrimSpace(fileType)), ".")
|
||||
if suffix == "." {
|
||||
suffix = ".table"
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "im-workbench-ai-table-*"+suffix)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := func() { _ = os.Remove(tmpPath) }
|
||||
if _, err := io.Copy(tmp, reader); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
return tmpPath, cleanup, nil
|
||||
}
|
||||
|
||||
func copyHeader(dst *http.Request, src *http.Request, key string) {
|
||||
if value := strings.TrimSpace(src.Header.Get(key)); value != "" {
|
||||
dst.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func bearerTokenFromHeader(value string) string {
|
||||
token, ok := strings.CutPrefix(strings.TrimSpace(value), "Bearer ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func (a *API) ossUploadConfig(ctx context.Context) (ossUploadConfig, error) {
|
||||
values, err := managerconfig.New(a.manager).ThirdPartyConfig(ctx, "im_third_party",
|
||||
"oss_enabled",
|
||||
"oss_endpoint",
|
||||
"oss_bucket",
|
||||
"oss_access_key_id",
|
||||
"oss_access_key_secret",
|
||||
"oss_serve_url",
|
||||
"oss_cache_control",
|
||||
)
|
||||
if err != nil {
|
||||
return ossUploadConfig{}, err
|
||||
}
|
||||
cfg := ossUploadConfig{
|
||||
Enabled: parseConfigBool(values["oss_enabled"]),
|
||||
Endpoint: strings.TrimSpace(values["oss_endpoint"]),
|
||||
Bucket: strings.TrimSpace(values["oss_bucket"]),
|
||||
AccessKeyID: strings.TrimSpace(values["oss_access_key_id"]),
|
||||
AccessKeySecret: strings.TrimSpace(values["oss_access_key_secret"]),
|
||||
ServeURL: strings.TrimSpace(values["oss_serve_url"]),
|
||||
CacheControl: strings.TrimSpace(values["oss_cache_control"]),
|
||||
}
|
||||
if cfg.ServeURL == "" {
|
||||
cfg.ServeURL = "/v0/file/s/"
|
||||
}
|
||||
if !cfg.Enabled || cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||||
return ossUploadConfig{}, fmt.Errorf("oss config incomplete")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseConfigBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newFileUploadID() int64 {
|
||||
var random [8]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
suffix := int64(binary.LittleEndian.Uint64(random[:]) & ((1 << 20) - 1))
|
||||
return (time.Now().UnixMilli() << 20) | suffix
|
||||
}
|
||||
|
||||
func formatFileUploadID(id int64) (publicID string, storageID string) {
|
||||
encrypted := encryptUID(id)
|
||||
publicID = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encrypted)
|
||||
storageID = strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(encrypted))
|
||||
return publicID, storageID
|
||||
}
|
||||
|
||||
func publicFileDownloadURL(r *http.Request, serveURL, fileName string) string {
|
||||
serveURL = strings.TrimSpace(serveURL)
|
||||
if strings.HasPrefix(serveURL, "http://") || strings.HasPrefix(serveURL, "https://") {
|
||||
return strings.TrimRight(serveURL, "/") + "/" + fileName
|
||||
}
|
||||
if serveURL == "" {
|
||||
serveURL = "/v0/file/s/"
|
||||
}
|
||||
if !strings.HasPrefix(serveURL, "/") {
|
||||
serveURL = "/" + serveURL
|
||||
}
|
||||
return absoluteURL(r, strings.TrimRight(serveURL, "/")+"/"+fileName)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestAITableAnalysisDocx(t *testing.T) {
|
||||
docx, err := aiTableAnalysisDocx(&store.AITableAnalysis{
|
||||
RecordID: "airec_tbl_test",
|
||||
FileName: "sales.xlsx",
|
||||
FileType: "xlsx",
|
||||
FileSize: 1234,
|
||||
Topic: "销售数据",
|
||||
MetricsJSON: `[{"name":"销售额","value":"12万","change_text":"+10%","trend":"up"}]`,
|
||||
AlertsJSON: `[{"level":"warning","title":"异常波动","description":"订单量下降"}]`,
|
||||
Interpretation: "整体销售额增长。",
|
||||
CreatedAt: time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(docx), int64(len(docx)))
|
||||
if err != nil {
|
||||
t.Fatalf("docx is not a zip: %v", err)
|
||||
}
|
||||
var document string
|
||||
for _, file := range zr.File {
|
||||
if file.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document = string(data)
|
||||
break
|
||||
}
|
||||
for _, want := range []string{"销售数据", "销售额", "异常波动", "整体销售额增长"} {
|
||||
if !strings.Contains(document, want) {
|
||||
t.Fatalf("document.xml missing %q: %s", want, document)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/ai"
|
||||
"git.118im.com/im_team/im_workbench/internal/events"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
aiWorkerCount = 2
|
||||
aiWorkerPollInterval = time.Second
|
||||
aiTaskRunTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
type aiDocumentTaskSnapshot struct {
|
||||
SourceText string `json:"source_text"`
|
||||
TitleHint string `json:"title_hint"`
|
||||
DocumentType string `json:"document_type"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type aiTableTaskSnapshot struct {
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Topic string `json:"topic"`
|
||||
SheetName string `json:"sheet_name"`
|
||||
Language string `json:"language"`
|
||||
RecordID string `json:"record_id"`
|
||||
}
|
||||
|
||||
type aiMeetingSummaryTaskSnapshot struct {
|
||||
MeetingID string `json:"meeting_id"`
|
||||
SummaryID string `json:"summary_id"`
|
||||
FileIDs []string `json:"file_ids"`
|
||||
SummaryType string `json:"summary_type"`
|
||||
ForceRegenerate bool `json:"force_regenerate"`
|
||||
}
|
||||
|
||||
func (a *API) StartAIWorkers(ctx context.Context) {
|
||||
if a == nil || a.store == nil || a.ai == nil {
|
||||
return
|
||||
}
|
||||
for i := 0; i < aiWorkerCount; i++ {
|
||||
go a.aiWorkerLoop(ctx, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) aiWorkerLoop(ctx context.Context, workerID int) {
|
||||
ticker := time.NewTicker(aiWorkerPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
task, err := a.store.ClaimQueuedAITask(ctx)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("ai worker claim failed: worker=%d err=%v", workerID, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.runAITask(ctx, task)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) runAITask(parent context.Context, task *store.AITask) {
|
||||
ctx, cancel := context.WithTimeout(parent, aiTaskRunTimeout)
|
||||
defer cancel()
|
||||
st, err := a.store.ForTenant(ctx, task.TenantID, task.TenantCode)
|
||||
if err != nil {
|
||||
log.Printf("ai task tenant store failed: task_id=%s tenant_id=%d err=%v", task.TaskID, task.TenantID, err)
|
||||
return
|
||||
}
|
||||
switch task.Feature {
|
||||
case "smart_document":
|
||||
a.runAIDocumentTask(ctx, st, task)
|
||||
case "smart_table":
|
||||
a.runAITableTask(ctx, st, task)
|
||||
case "meeting_summary":
|
||||
a.runAIMeetingSummaryTask(ctx, st, task)
|
||||
default:
|
||||
a.finishAITaskFailure(ctx, st, task, "AI_TASK_UNSUPPORTED", "unsupported ai task feature")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) runAIDocumentTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiDocumentTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
result, err := a.ai.GenerateDocument(ctx, strings.TrimSpace(input.SourceText), strings.TrimSpace(input.TitleHint), strings.TrimSpace(input.DocumentType), strings.TrimSpace(input.Language))
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.SourceText))
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{
|
||||
"title": result.Title,
|
||||
"summary": result.Summary,
|
||||
"content_markdown": result.ContentMarkdown,
|
||||
"content_plain": result.ContentPlain,
|
||||
"sections": result.Sections,
|
||||
"source_excerpt": excerpt(input.SourceText, 200),
|
||||
"can_save": true,
|
||||
}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, "", taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai document task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := taskUserID(task)
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_document", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.SourceText), OutputHash: hashText(result.ContentMarkdown),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "smart_document", result.Title, result.Summary, map[string]any{
|
||||
"task_id": task.TaskID, "feature": "smart_document", "status": "succeeded", "progress": 100, "result_type": "text",
|
||||
"result": compactAITextResult(result.Title, result.Summary, result.ContentMarkdown, result.ContentPlain),
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) runAITableTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiTableTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
fileType := strings.ToLower(strings.TrimSpace(input.FileType))
|
||||
fileMeta := st.AIFileMetadata(ctx, input.FileID)
|
||||
profile := a.tableProfileForAIContext(ctx, input.FileID, fileMeta, fileType)
|
||||
result, err := a.ai.AnalyzeTable(ctx, ai.TableAnalysisInput{
|
||||
Topic: input.Topic, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize, Profile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.FileID+":"+input.Topic))
|
||||
return
|
||||
}
|
||||
recordID := strings.TrimSpace(input.RecordID)
|
||||
if recordID == "" {
|
||||
recordID = "airec_tbl_" + randomToken(10)
|
||||
}
|
||||
if err := st.SaveAITableAnalysis(ctx, task.TenantCode, taskUserID(task), store.AIRecord{
|
||||
RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160),
|
||||
Status: "succeeded", TaskID: task.TaskID,
|
||||
}, store.AITableAnalysis{
|
||||
RecordID: recordID, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize,
|
||||
Topic: input.Topic, MetricsJSON: mustJSON(result.Metrics, "[]"), PreviewJSON: mustJSON(result.Preview, "{}"),
|
||||
AlertsJSON: mustJSON(result.Alerts, "[]"), Interpretation: result.Interpretation,
|
||||
PromptVersion: "smart_table_v1", Model: result.Usage.Model,
|
||||
}); err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, recordID, taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai table task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_table", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.FileID + ":" + input.Topic), OutputHash: hashText(result.Interpretation),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, "smart_table", result.Title, result.Summary, map[string]any{
|
||||
"task_id": task.TaskID, "feature": "smart_table", "status": "succeeded", "progress": 100, "result_type": "record",
|
||||
"record_id": recordID,
|
||||
"result": map[string]any{"record_id": recordID, "title": result.Title, "summary": excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160)},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) runAIMeetingSummaryTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiMeetingSummaryTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
userID := taskUserID(task)
|
||||
meeting, err := st.Meeting(ctx, userID, input.MeetingID)
|
||||
if err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
files, err := st.MeetingNoteFiles(ctx, userID, input.MeetingID)
|
||||
if err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
fileNames, fileIDs := meetingSummaryFiles(files, input.FileIDs)
|
||||
result, err := a.ai.SummarizeMeeting(ctx, ai.MeetingSummaryInput{
|
||||
MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||
Participants: meetingSummaryParticipants(*meeting), Files: fileNames,
|
||||
})
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.MeetingID+strings.Join(fileIDs, ",")))
|
||||
return
|
||||
}
|
||||
summaryID := strings.TrimSpace(input.SummaryID)
|
||||
if summaryID == "" {
|
||||
summaryID = "aisum_" + randomToken(10)
|
||||
}
|
||||
if err := st.UpsertAIMeetingSummary(ctx, task.TenantCode, userID, store.AIMeetingSummary{
|
||||
SummaryID: summaryID, MeetingID: input.MeetingID, TaskID: task.TaskID,
|
||||
ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"),
|
||||
ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"),
|
||||
PromptVersion: "meeting_summary_v1", Model: result.Usage.Model,
|
||||
}); err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{
|
||||
"summary_id": summaryID, "meeting_id": input.MeetingID,
|
||||
"conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems,
|
||||
}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, summaryID, taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai meeting summary task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "meeting_summary", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.MeetingID + strings.Join(fileIDs, ",")), OutputHash: hashText(mustJSON(result.Conclusions, "[]")),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "meeting_summary", meeting.Title, "", map[string]any{
|
||||
"task_id": task.TaskID, "feature": "meeting_summary", "status": "succeeded", "progress": 100, "result_type": "summary",
|
||||
"meeting_id": input.MeetingID, "summary_id": summaryID,
|
||||
"result": map[string]any{"meeting_id": input.MeetingID, "summary_id": summaryID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) finishAITaskAIError(ctx context.Context, st *store.Store, task *store.AITask, err error, inputHash string) {
|
||||
reason := aiErrorReason(err)
|
||||
a.finishAITaskFailure(ctx, st, task, reason, err.Error())
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: task.Feature, TaskID: task.TaskID,
|
||||
Provider: "deepseek", Status: "failed", ErrorReason: reason, InputHash: inputHash,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) finishAITaskFailure(ctx context.Context, st *store.Store, task *store.AITask, reason, message string) {
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "failed", 100, "", nil, reason, message)
|
||||
if err != nil {
|
||||
log.Printf("ai task failure update failed: task_id=%s reason=%s err=%v", task.TaskID, reason, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, task.Feature, "AI 任务失败", message, map[string]any{
|
||||
"task_id": task.TaskID, "feature": task.Feature, "status": "failed", "progress": 100, "result_type": "none",
|
||||
"error": map[string]any{"reason": reason, "message": message},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) publishAITaskEvent(ctx context.Context, tenantID int64, tenantCode string, userID int64, taskID, feature, title, summary string, data map[string]any) {
|
||||
if a == nil || a.events == nil || userID <= 0 || strings.TrimSpace(taskID) == "" {
|
||||
return
|
||||
}
|
||||
event := events.Event{
|
||||
EventID: events.StableEventID(tenantID, "ai", "workbench.ai.task.updated", taskID+":"+stringValue(data["status"]), userID),
|
||||
TenantID: tenantID,
|
||||
TenantCode: tenantCode,
|
||||
ActorUserID: userID,
|
||||
Module: "ai",
|
||||
Action: "workbench.ai.task.updated",
|
||||
ResourceID: taskID,
|
||||
TargetUserIDs: []int64{userID},
|
||||
Title: strings.TrimSpace(title),
|
||||
Summary: strings.TrimSpace(summary),
|
||||
OccurredAt: time.Now().UTC().Round(time.Millisecond),
|
||||
Data: data,
|
||||
}
|
||||
pubCtx, cancel := context.WithTimeout(ctx, workbenchEventPublishTimeout)
|
||||
defer cancel()
|
||||
if err := a.events.Publish(pubCtx, event); err != nil {
|
||||
log.Printf("ai task event publish failed: event_id=%s task_id=%s feature=%s err=%v", event.EventID, taskID, feature, err)
|
||||
}
|
||||
}
|
||||
|
||||
func aiTaskSnapshot(value any) (sql.NullString, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return sql.NullString{}, err
|
||||
}
|
||||
return sql.NullString{String: string(data), Valid: true}, nil
|
||||
}
|
||||
|
||||
func decodeTaskSnapshot(task *store.AITask, dst any) bool {
|
||||
if task == nil || !task.InputSnapshot.Valid || strings.TrimSpace(task.InputSnapshot.String) == "" {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal([]byte(task.InputSnapshot.String), dst) == nil
|
||||
}
|
||||
|
||||
func (a *API) tableProfileForAIContext(ctx context.Context, fileID string, meta store.AIFileMetadata, fileType string) ai.TableProfile {
|
||||
if path := resolveTableFilePath(fileID, meta.Location); path != "" {
|
||||
return tableProfileFromPath(path, fileType)
|
||||
}
|
||||
if strings.TrimSpace(meta.Location) == "" {
|
||||
return ai.TableProfile{Parsed: false, Message: "source file metadata is not available"}
|
||||
}
|
||||
path, cleanup, err := a.downloadOSSObjectToTemp(ctx, meta.Location, fileType)
|
||||
if err != nil {
|
||||
return ai.TableProfile{Parsed: false, Message: "source file is not available: " + err.Error()}
|
||||
}
|
||||
defer cleanup()
|
||||
return tableProfileFromPath(path, fileType)
|
||||
}
|
||||
|
||||
func taskUserID(task *store.AITask) int64 {
|
||||
if task == nil {
|
||||
return 0
|
||||
}
|
||||
userID, _ := strconv.ParseInt(strings.TrimSpace(task.UserID), 10, 64)
|
||||
return userID
|
||||
}
|
||||
|
||||
func compactAITextResult(title, summary, markdown, plain string) map[string]any {
|
||||
result := map[string]any{"title": title, "summary": summary, "content_plain": plain}
|
||||
if len([]rune(markdown)) <= 8000 {
|
||||
result["content_markdown"] = markdown
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
+37
-1166
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestAttendanceCheckinPayloadReturnsJSONObjectFields(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 1, 27, 49, 0, time.UTC)
|
||||
payload := attendanceCheckinPayload(store.AttendanceCheckin{
|
||||
CheckinID: "attc_1",
|
||||
Action: "check_in",
|
||||
ClientTime: now,
|
||||
ServerTime: now,
|
||||
Result: "normal",
|
||||
Location: `{"name":"当前位置","latitude":22.929670335639365}`,
|
||||
Wifi: `{}`,
|
||||
Device: `{"platform":"ios","device_id":"C7A76C6A-FC57-4CED-B1C1-CE5199944F8C"}`,
|
||||
})
|
||||
|
||||
location, ok := payload["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location=%T, want map[string]any", payload["location"])
|
||||
}
|
||||
if location["name"] != "当前位置" {
|
||||
t.Fatalf("location=%v", location)
|
||||
}
|
||||
if _, ok := payload["wifi"].(map[string]any); !ok {
|
||||
t.Fatalf("wifi=%T, want map[string]any", payload["wifi"])
|
||||
}
|
||||
device, ok := payload["device"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("device=%T, want map[string]any", payload["device"])
|
||||
}
|
||||
if device["platform"] != "ios" {
|
||||
t.Fatalf("device=%v", device)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
result, err := a.finance.PaymentAccounts(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.PaymentAccountInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body finance.QuoteInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.Quote(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrders(r.Context(), principal.TenantID, principal.MemberID,
|
||||
page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.CreateWithdrawalInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreateWithdrawal(r.Context(), principal.TenantID, principal.MemberID,
|
||||
r.Header.Get("Idempotency-Key"), body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/withdrawal-orders/"), "/")
|
||||
if orderIDText == "" || strings.Contains(orderIDText, "/") {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateFinanceMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.finance == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, finance.ErrAccountTypeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ACCOUNT_TYPE_INVALID", "validation failed", map[string]any{"field": "account_type"})
|
||||
case errors.Is(err, finance.ErrAccountFieldsInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAYMENT_ACCOUNT_FIELDS_INVALID", "validation failed", nil)
|
||||
case errors.Is(err, finance.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "trade_password"})
|
||||
case errors.Is(err, finance.ErrTradePasswordUnset):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TRADE_PASSWORD_NOT_SET", "trade password is not set", nil)
|
||||
case errors.Is(err, finance.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many trade password attempts", map[string]any{"retry_after_seconds": 900})
|
||||
case errors.Is(err, finance.ErrCurrencyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"})
|
||||
case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil)
|
||||
case errors.Is(err, finance.ErrIdempotencyKey):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"})
|
||||
case errors.Is(err, finance.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, finance.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, finance.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, finance.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, store.ErrPaymentAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PAYMENT_ACCOUNT_EXISTS", "payment account already exists", nil)
|
||||
case errors.Is(err, store.ErrPaymentAccountInvalid):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "PAYMENT_ACCOUNT_INVALID", "payment account is unavailable or unverified", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalChannel):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalLimit):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil)
|
||||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
|
||||
case errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "QR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "qr_attachment_id"})
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, finance.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const internalSignatureWindow = 5 * time.Minute
|
||||
|
||||
func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err := a.verifyInternalAdminSignature(r, body); err != nil {
|
||||
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error {
|
||||
secret := strings.TrimSpace(a.authCfg.InternalSecret)
|
||||
if secret == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name"))
|
||||
if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected {
|
||||
return ErrForbidden
|
||||
}
|
||||
timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp"))
|
||||
nonce := strings.TrimSpace(r.Header.Get("X-Nonce"))
|
||||
signature := strings.TrimSpace(r.Header.Get("X-Signature"))
|
||||
if timestampRaw == "" || nonce == "" || signature == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
ts, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||
if err != nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
when := time.Unix(ts, 0)
|
||||
if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow {
|
||||
return ErrForbidden
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
signingText := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestampRaw,
|
||||
nonce,
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(signingText))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) {
|
||||
t.Fatal("validateMailboxSubmission returned true for too-long content")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body apiError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if body.Reason != reasonValidationFailed {
|
||||
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||
}
|
||||
if body.Details["field"] != "content" {
|
||||
t.Fatalf("details=%v", body.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
writeStoreError(rec, req, store.ErrPolicy)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body apiError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if body.Reason != reasonValidationFailed {
|
||||
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
categoryID, ok := optionalNonNegativeInt64(r.URL.Query().Get("category_id"))
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrCategoryIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Products(r.Context(), principal.TenantID, categoryID, page, pageSize)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) manualProduct(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
productIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/manual-products/"), "/")
|
||||
if productIDText == "" || strings.Contains(productIDText, "/") {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
productID, err := strconv.ParseInt(productIDText, 10, 64)
|
||||
if err != nil || productID <= 0 {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Product(r.Context(), principal.TenantID, productID)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func optionalNonNegativeInt64(value string) (int64, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
return parsed, err == nil && parsed >= 0
|
||||
}
|
||||
|
||||
func writeMallError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, mall.ErrCategoryIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CATEGORY_ID_INVALID", "validation failed", map[string]any{"field": "category_id"})
|
||||
case errors.Is(err, mall.ErrProductIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRODUCT_ID_INVALID", "validation failed", map[string]any{"field": "product_id"})
|
||||
case errors.Is(err, mall.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, mall.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "PRODUCT_NOT_FOUND", "manual product not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func workbenchModulePayloads(modules []store.WorkbenchModule) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(modules))
|
||||
for _, module := range modules {
|
||||
items = append(items, map[string]any{
|
||||
"code": module.Code,
|
||||
"name": module.Name,
|
||||
"icon_file_id": module.IconFileID,
|
||||
"route": module.Route,
|
||||
"sort_order": module.SortOrder,
|
||||
"unread_count": module.UnreadCount,
|
||||
"enabled": module.Enabled,
|
||||
"permission": firstNonEmpty(module.Permission, "enabled"),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationDepartmentPayloads(departments []store.OrganizationDepartment) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(departments))
|
||||
for _, department := range departments {
|
||||
items = append(items, organizationDepartmentPayload(department))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationDepartmentPayload(department store.OrganizationDepartment) map[string]any {
|
||||
return map[string]any{
|
||||
"department_id": department.DepartmentID,
|
||||
"name": department.Name,
|
||||
"parent_id": department.ParentID,
|
||||
"child_count": department.ChildCount,
|
||||
"member_count": department.MemberCount,
|
||||
"version": department.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func organizationMemberPayloads(members []store.OrganizationMember) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(members))
|
||||
for _, member := range members {
|
||||
items = append(items, organizationMemberPayload(member))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationMemberPayload(member store.OrganizationMember) map[string]any {
|
||||
payload := map[string]any{
|
||||
"user_id": formatUserID(member.UserID),
|
||||
"department_id": member.DepartmentID,
|
||||
"display_name": member.DisplayName,
|
||||
"position": member.Position,
|
||||
"avatar_file_id": member.AvatarFileID,
|
||||
"status": member.Status,
|
||||
"contactable": member.Contactable,
|
||||
"version": member.Version,
|
||||
}
|
||||
if member.Mobile != "" {
|
||||
payload["mobile"] = member.Mobile
|
||||
}
|
||||
if member.Email != "" {
|
||||
payload["email"] = member.Email
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func attendanceConfigPayload(config *store.AttendanceConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"date": config.Date.Format("2006-01-02"),
|
||||
"timezone": config.Timezone,
|
||||
"rule": attendanceRulePayload(config.Rule),
|
||||
"shifts": attendanceShiftPayloads(config.Shifts),
|
||||
"allowed_actions": []string{"check_in", "check_out"},
|
||||
"today_record": attendanceRecordPayload(config.TodayRecord),
|
||||
"permissions": map[string]any{
|
||||
"can_checkin": true,
|
||||
"can_view_managed": true,
|
||||
"can_appeal": config.Rule.AppealAllowed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceRulePayload(rule store.AttendanceRule) map[string]any {
|
||||
data := map[string]any{
|
||||
"rule_id": rule.RuleID,
|
||||
"name": rule.Name,
|
||||
"workday": rule.Workday,
|
||||
"location_required": rule.LocationRequired,
|
||||
"wifi_required": rule.WifiRequired,
|
||||
"allow_outside_checkin": rule.AllowOutsideCheckin,
|
||||
"appeal_allowed": rule.AppealAllowed,
|
||||
}
|
||||
if rule.AppealDeadline != nil {
|
||||
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func attendanceShiftPayloads(shifts []store.AttendanceShift) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(shifts))
|
||||
for _, shift := range shifts {
|
||||
items = append(items, map[string]any{
|
||||
"shift_id": shift.ShiftID,
|
||||
"name": shift.Name,
|
||||
"work_start": shift.WorkStart,
|
||||
"work_end": shift.WorkEnd,
|
||||
"checkin_start": shift.CheckinStart,
|
||||
"checkout_end": shift.CheckoutEnd,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayloads(records []store.AttendanceRecord) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(records))
|
||||
for i := range records {
|
||||
items = append(items, attendanceRecordPayload(&records[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"record_id": record.RecordID,
|
||||
"user_id": formatUserID(record.UserID),
|
||||
"date": record.Date.Format("2006-01-02"),
|
||||
"rule_id": record.RuleID,
|
||||
"shift_id": record.ShiftID,
|
||||
"status": record.Status,
|
||||
"checkins": attendanceCheckinPayloads(record.Checkins),
|
||||
"exceptions": jsonList(record.Exceptions),
|
||||
"appeal_status": record.AppealStatus,
|
||||
"version": record.Version,
|
||||
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCheckinPayloads(checkins []store.AttendanceCheckin) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(checkins))
|
||||
for _, checkin := range checkins {
|
||||
items = append(items, attendanceCheckinPayload(checkin))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceCheckinPayload(checkin store.AttendanceCheckin) map[string]any {
|
||||
return map[string]any{
|
||||
"checkin_id": checkin.CheckinID,
|
||||
"action": checkin.Action,
|
||||
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
|
||||
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
|
||||
"result": checkin.Result,
|
||||
"location": jsonObject(checkin.Location),
|
||||
"wifi": jsonObject(checkin.Wifi),
|
||||
"device": jsonObject(checkin.Device),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceStatsPayload(stats *store.AttendanceStats) map[string]any {
|
||||
if stats == nil {
|
||||
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
|
||||
}
|
||||
return map[string]any{
|
||||
"expected_count": stats.ExpectedCount,
|
||||
"checked_count": stats.CheckedCount,
|
||||
"exception_count": stats.ExceptionCount,
|
||||
"not_checked_count": stats.NotCheckedCount,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceAppealPayloads(appeals []store.AttendanceAppeal) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(appeals))
|
||||
for i := range appeals {
|
||||
items = append(items, attendanceAppealPayload(&appeals[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any {
|
||||
if appeal == nil {
|
||||
return nil
|
||||
}
|
||||
data := map[string]any{
|
||||
"appeal_id": appeal.AppealID,
|
||||
"record_id": appeal.RecordID,
|
||||
"user_id": formatUserID(appeal.UserID),
|
||||
"appeal_type": appeal.AppealType,
|
||||
"reason": appeal.Reason,
|
||||
"target_action": appeal.TargetAction,
|
||||
"attachment_file_ids": jsonList(appeal.AttachmentFileIDs),
|
||||
"status": appeal.Status,
|
||||
"version": appeal.Version,
|
||||
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if appeal.TargetTime != nil {
|
||||
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(meetings))
|
||||
for _, meeting := range meetings {
|
||||
items = append(items, meetingPayload(meeting, viewer))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
|
||||
participants := meetingParticipantPayloads(meeting.Participants)
|
||||
recorderID := meeting.RecorderID
|
||||
if recorderID == 0 {
|
||||
recorderID = meeting.OrganizerID
|
||||
}
|
||||
organizer := userSummary(meeting.OrganizerID, meeting.Organizer)
|
||||
recorder := userSummary(recorderID, meeting.Recorder)
|
||||
state := meetingState(meeting)
|
||||
data := map[string]any{
|
||||
"meeting_id": meeting.MeetingID,
|
||||
"title": meeting.Title,
|
||||
"organizer_id": formatUserID(meeting.OrganizerID),
|
||||
"organizer": organizer,
|
||||
"organizer_display_name": organizer["display_name"],
|
||||
"recorder_user_id": formatUserID(recorderID),
|
||||
"recorder": recorder,
|
||||
"recorder_display_name": recorder["display_name"],
|
||||
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
||||
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
|
||||
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
|
||||
"location": meeting.Location,
|
||||
"state": state,
|
||||
"reminder_minutes": jsonList(meeting.ReminderMinutes),
|
||||
"version": meeting.Version,
|
||||
"participants": participants,
|
||||
"participant_count": len(participants),
|
||||
"participant_names": meetingParticipantNames(participants),
|
||||
"permissions": meetingPermissions(meeting, viewer),
|
||||
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if meeting.CanceledAt != nil {
|
||||
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
for _, participant := range meeting.Participants {
|
||||
if participant.UserID == viewer {
|
||||
data["my_response"] = participant.Response
|
||||
break
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
summary := userSummary(participant.UserID, participant.User)
|
||||
item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]}
|
||||
if avatarFileID, ok := summary["avatar_file_id"]; ok {
|
||||
item["avatar_file_id"] = avatarFileID
|
||||
}
|
||||
if participant.ResponseAt != nil {
|
||||
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingNoteListPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(meetings))
|
||||
for _, meeting := range meetings {
|
||||
item := meetingPayload(meeting, viewer)
|
||||
item["note_status"] = "empty"
|
||||
item["file_count"] = 0
|
||||
item["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteFile, viewer int64) map[string]any {
|
||||
meetingData := meetingPayload(meeting, viewer)
|
||||
fileItems := make([]map[string]any, 0, len(files))
|
||||
for _, file := range files {
|
||||
fileItems = append(fileItems, meetingNoteFilePayload(file, viewer))
|
||||
}
|
||||
data := map[string]any{}
|
||||
for k, v := range meetingData {
|
||||
data[k] = v
|
||||
}
|
||||
data["meeting"] = meetingData
|
||||
data["note_status"] = "empty"
|
||||
if len(files) > 0 {
|
||||
data["note_status"] = "available"
|
||||
}
|
||||
data["file_count"] = len(files)
|
||||
data["files"] = fileItems
|
||||
data["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
|
||||
extension := meetingNoteFileExtension(file.FileName, file.MIMEType)
|
||||
return map[string]any{
|
||||
"note_file_id": file.NoteFileID,
|
||||
"meeting_id": file.MeetingID,
|
||||
"file_id": file.FileID,
|
||||
"name": file.FileName,
|
||||
"file_name": file.FileName,
|
||||
"mime": file.MIMEType,
|
||||
"mime_type": file.MIMEType,
|
||||
"size": file.FileSize,
|
||||
"file_size": file.FileSize,
|
||||
"file_extension": extension,
|
||||
"file_etag": file.FileETag,
|
||||
"status": file.Status,
|
||||
"preview_status": file.PreviewStatus,
|
||||
"preview_mode": file.PreviewMode,
|
||||
"preview_file_id": file.PreviewFileID,
|
||||
"uploader_user_id": formatUserID(file.UploaderID),
|
||||
"uploader": userSummary(file.UploaderID, file.Uploader),
|
||||
"version": file.Version,
|
||||
"permissions": map[string]any{
|
||||
"can_preview": file.Status == "active",
|
||||
"can_download": file.Status == "active",
|
||||
"can_replace": file.Status == "active" && file.UploaderID == viewer,
|
||||
"can_delete": file.Status == "active" && file.UploaderID == viewer,
|
||||
},
|
||||
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func meetingNoteFileExtension(fileName, mimeType string) string {
|
||||
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(fileName)), ".")); ext != "" {
|
||||
return ext
|
||||
}
|
||||
exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType))
|
||||
if len(exts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimPrefix(exts[0], "."))
|
||||
}
|
||||
|
||||
func dailyReportPayloads(reports []store.DailyReport) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(reports))
|
||||
for _, report := range reports {
|
||||
items = append(items, dailyReportPayload(report))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func dailyReportPayload(report store.DailyReport) map[string]any {
|
||||
data := map[string]any{
|
||||
"report_id": report.ReportID,
|
||||
"author_id": formatUserID(report.AuthorID),
|
||||
"report_date": report.ReportDate.Format("2006-01-02"),
|
||||
"content": report.Content,
|
||||
"content_short": preview(report.Content),
|
||||
"attachments": jsonList(report.Attachments),
|
||||
"status": report.Status,
|
||||
"read_state": report.ReadState,
|
||||
"version": report.Version,
|
||||
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if report.SubmittedAt != nil {
|
||||
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(reports))
|
||||
for _, report := range reports {
|
||||
items = append(items, monthlyReportPayload(report))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func monthlyReportPayload(report store.MonthlyReport) map[string]any {
|
||||
data := map[string]any{
|
||||
"report_id": report.ReportID,
|
||||
"author_id": formatUserID(report.AuthorID),
|
||||
"month": report.Month,
|
||||
"work_summary": report.WorkSummary,
|
||||
"next_plan": report.NextPlan,
|
||||
"attachments": jsonList(report.Attachments),
|
||||
"status": report.Status,
|
||||
"read_state": report.ReadState,
|
||||
"version": report.Version,
|
||||
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if report.SubmittedAt != nil {
|
||||
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func reportStatsPayload(stats *store.ReportStats) map[string]any {
|
||||
if stats == nil {
|
||||
return map[string]any{
|
||||
"expected_count": 0,
|
||||
"submitted_count": 0,
|
||||
"missing_count": 0,
|
||||
"scope": map[string]any{"department_id": ""},
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"expected_count": stats.ExpectedCount,
|
||||
"submitted_count": stats.SubmittedCount,
|
||||
"missing_count": stats.MissingCount,
|
||||
"scope": map[string]any{"department_id": stats.DepartmentID},
|
||||
}
|
||||
}
|
||||
|
||||
func mailboxConfigPayload(config store.MailboxConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": config.Enabled,
|
||||
"allow_anonymous": config.AllowAnonymous,
|
||||
"required_fields": jsonList(config.RequiredFields),
|
||||
"max_length": config.MaxLength,
|
||||
"attachment_policy": jsonObject(config.AttachmentPolicy),
|
||||
"notice": map[string]any{
|
||||
"title": config.NoticeTitle,
|
||||
"content": config.NoticeContent,
|
||||
"version": config.Version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any {
|
||||
return map[string]any{
|
||||
"submission_id": submission.SubmissionID,
|
||||
"sender_id": formatUserID(submission.SenderID),
|
||||
"subject": submission.Subject,
|
||||
"anonymous": submission.Anonymous,
|
||||
"attachment_file_ids": jsonList(submission.AttachmentFileIDs),
|
||||
"notice_version": submission.NoticeVersion,
|
||||
"status": submission.Status,
|
||||
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||
isOrganizer := meeting.OrganizerID == viewer
|
||||
isScheduled := meetingState(meeting) == "scheduled"
|
||||
return map[string]any{
|
||||
"can_update": isOrganizer && isScheduled,
|
||||
"can_cancel": isOrganizer && isScheduled,
|
||||
"can_remind": isOrganizer && isScheduled,
|
||||
"can_respond": !isOrganizer && isScheduled,
|
||||
}
|
||||
}
|
||||
|
||||
func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||
recorderID := meeting.RecorderID
|
||||
if recorderID == 0 {
|
||||
recorderID = meeting.OrganizerID
|
||||
}
|
||||
readable := meetingState(meeting) == "ended"
|
||||
canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer)
|
||||
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
|
||||
}
|
||||
|
||||
func userSummary(userID int64, summary store.UserSummary) map[string]any {
|
||||
displayName := strings.TrimSpace(summary.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = formatUserID(userID)
|
||||
}
|
||||
data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName}
|
||||
if strings.TrimSpace(summary.AvatarFileID) != "" {
|
||||
data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingState(meeting store.Meeting) string {
|
||||
state := strings.TrimSpace(meeting.State)
|
||||
if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) {
|
||||
return "ended"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func meetingParticipantNames(participants []map[string]any) []string {
|
||||
names := make([]string, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
name, _ := participant["display_name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func jsonList(raw string) []any {
|
||||
var items []any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &items); err != nil {
|
||||
return []any{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func jsonObject(raw string) map[string]any {
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &item); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func preview(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= 40 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:40])
|
||||
}
|
||||
|
||||
func timeRange(startAt, endAt time.Time) string {
|
||||
if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") {
|
||||
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04")
|
||||
}
|
||||
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestPayloadUserIDsAreStrings(t *testing.T) {
|
||||
now := time.Date(2026, 9, 16, 9, 30, 0, 0, time.UTC)
|
||||
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payloads := []map[string]any{
|
||||
organizationMemberPayload(store.OrganizationMember{UserID: userA}),
|
||||
attendanceRecordPayload(&store.AttendanceRecord{RecordID: "att_1", UserID: userA, Date: now, CreatedAt: now, UpdatedAt: now}),
|
||||
attendanceAppealPayload(&store.AttendanceAppeal{AppealID: "atta_1", UserID: userA, SubmittedAt: now, CreatedAt: now, UpdatedAt: now}),
|
||||
meetingPayload(store.Meeting{
|
||||
MeetingID: "mtg_1",
|
||||
OrganizerID: userA,
|
||||
RecorderID: userB,
|
||||
StartAt: now,
|
||||
EndAt: now.Add(time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Participants: []store.MeetingParticipant{
|
||||
{UserID: userA, Response: "accepted"},
|
||||
{UserID: userB, Response: "pending"},
|
||||
},
|
||||
}, userA),
|
||||
meetingNoteFilePayload(store.MeetingNoteFile{NoteFileID: "mnote_1", UploaderID: userA, CreatedAt: now, UpdatedAt: now}, userA),
|
||||
dailyReportPayload(store.DailyReport{ReportID: "dr_1", AuthorID: userA, ReportDate: now, CreatedAt: now, UpdatedAt: now}),
|
||||
monthlyReportPayload(store.MonthlyReport{ReportID: "mr_1", AuthorID: userA, CreatedAt: now, UpdatedAt: now}),
|
||||
mailboxSubmissionPayload(store.MailboxSubmission{SubmissionID: "mb_1", SenderID: userA, SubmittedAt: now}),
|
||||
}
|
||||
|
||||
for _, payload := range payloads {
|
||||
assertNoNumericUserIDs(t, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload := meetingPayload(store.Meeting{
|
||||
MeetingID: "mtg_1",
|
||||
Title: "周会",
|
||||
OrganizerID: userA,
|
||||
RecorderID: userA,
|
||||
Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||
Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||
StartAt: now.Add(-2 * time.Hour),
|
||||
EndAt: now.Add(-time.Hour),
|
||||
State: "scheduled",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Participants: []store.MeetingParticipant{
|
||||
{UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}},
|
||||
},
|
||||
}, userA)
|
||||
|
||||
if payload["state"] != "ended" {
|
||||
t.Fatalf("state=%v, want ended", payload["state"])
|
||||
}
|
||||
if payload["organizer_display_name"] != "张三" {
|
||||
t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"])
|
||||
}
|
||||
names, ok := payload["participant_names"].([]string)
|
||||
if !ok || len(names) != 1 || names[0] != "李四" {
|
||||
t.Fatalf("participant_names=%#v", payload["participant_names"])
|
||||
}
|
||||
participants, ok := payload["participants"].([]map[string]any)
|
||||
if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" {
|
||||
t.Fatalf("participants=%#v", payload["participants"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingNoteFilePayloadKeepsOriginalNameAndExtension(t *testing.T) {
|
||||
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||
NoteFileID: "mnote_1",
|
||||
FileID: "file_1",
|
||||
FileName: "会议纪要.pdf",
|
||||
MIMEType: "application/pdf",
|
||||
FileSize: 2048,
|
||||
UploaderID: 1,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, 1)
|
||||
if payload["file_name"] != "会议纪要.pdf" || payload["name"] != "会议纪要.pdf" {
|
||||
t.Fatalf("payload names = file_name:%v name:%v", payload["file_name"], payload["name"])
|
||||
}
|
||||
if payload["file_extension"] != "pdf" {
|
||||
t.Fatalf("file_extension = %v, want pdf", payload["file_extension"])
|
||||
}
|
||||
if payload["mime"] != "application/pdf" || payload["size"] != int64(2048) {
|
||||
t.Fatalf("payload aliases = mime:%v size:%v", payload["mime"], payload["size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingNoteFilePayloadUsesUploaderSummary(t *testing.T) {
|
||||
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||
NoteFileID: "mnote_1",
|
||||
UploaderID: 1573637795346386944,
|
||||
Uploader: store.UserSummary{
|
||||
UserID: 1573637795346386944,
|
||||
DisplayName: "jcharles3",
|
||||
},
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, 1573637795346386944)
|
||||
uploader, ok := payload["uploader"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("uploader=%#v", payload["uploader"])
|
||||
}
|
||||
if uploader["display_name"] != "jcharles3" {
|
||||
t.Fatalf("uploader.display_name=%v, want jcharles3", uploader["display_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoNumericUserIDs(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
if isUserIDKey(key) {
|
||||
if _, ok := item.(string); !ok {
|
||||
t.Fatalf("%s is %T, want string", key, item)
|
||||
}
|
||||
}
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isUserIDKey(key string) bool {
|
||||
return key == "user_id" ||
|
||||
key == "organizer_id" ||
|
||||
key == "recorder_user_id" ||
|
||||
key == "uploader_user_id" ||
|
||||
key == "author_id" ||
|
||||
key == "sender_id" ||
|
||||
strings.HasSuffix(key, "_user_id")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
}
|
||||
|
||||
func (a *API) home(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
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := a.portal.Home(r.Context(), principal.TenantID, principal.MemberID, profile)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) banners(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
|
||||
}
|
||||
position := strings.TrimSpace(r.URL.Query().Get("position"))
|
||||
if position == "" {
|
||||
position = "home_top"
|
||||
}
|
||||
items, err := a.portal.Banners(r.Context(), principal.TenantID, position)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/v1/contents/")
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if strings.Contains(key, "/") {
|
||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||
return
|
||||
}
|
||||
tenantID := a.auth.DefaultTenantID()
|
||||
if _, public := publicContentKeys[key]; !public {
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID = principal.TenantID
|
||||
}
|
||||
result, err := a.portal.Content(r.Context(), tenantID, key)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) memberLevels(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
|
||||
}
|
||||
items, err := a.portal.MemberLevels(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
func writePortalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, portal.ErrPositionInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"})
|
||||
case errors.Is(err, portal.ErrContentKeyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type avatarRequest struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type tradePasswordRequest struct {
|
||||
LoginPassword string `json:"login_password"`
|
||||
OldTradePassword string `json:"old_trade_password,omitempty"`
|
||||
NewTradePassword string `json:"new_trade_password"`
|
||||
}
|
||||
|
||||
func (a *API) memberProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, profile)
|
||||
}
|
||||
|
||||
func (a *API) memberAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
methodNotAllowed(w, r, http.MethodPatch)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body avatarRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
avatar, err := a.accounts.ChangeAvatar(r.Context(), principal.TenantID, principal.MemberID, body.AttachmentID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, avatar)
|
||||
}
|
||||
|
||||
func (a *API) memberPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangePasswordInput{
|
||||
OldPassword: body.OldPassword, NewPassword: body.NewPassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.auth.RevokeMemberSessions(r.Context(), principal.TenantID, principal.MemberID); err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) memberTradePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body tradePasswordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangeTradePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangeTradePasswordInput{
|
||||
LoginPassword: body.LoginPassword, OldTradePassword: body.OldTradePassword,
|
||||
NewTradePassword: body.NewTradePassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) authenticateMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.auth == nil || a.accounts == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, err := a.auth.Authenticate(r)
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
if errors.Is(err, auth.ErrForbidden) {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
} else if errors.Is(err, auth.ErrAuthRequired) {
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
} else {
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func writeProfileError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrAttachmentInvalid), errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "AVATAR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "attachment_id"})
|
||||
case errors.Is(err, accountsvc.ErrCurrentPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENT_PASSWORD_INVALID", "current password is invalid", map[string]any{"field": "old_password"})
|
||||
case errors.Is(err, accountsvc.ErrLoginPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "LOGIN_PASSWORD_INVALID", "login password is invalid", map[string]any{"field": "login_password"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "NEW_PASSWORD_INVALID", "validation failed", map[string]any{"field": "new_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePasswordUnset):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_REQUIRED", "old trade password is required", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_INVALID", "old trade password is invalid", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "new_trade_password"})
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ACCOUNT_NOT_FOUND", "account not found", nil)
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,15 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonMalformedRequest = "MALFORMED_REQUEST"
|
||||
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
||||
reasonAuthRequired = "AUTH_REQUIRED"
|
||||
reasonForbidden = "FORBIDDEN"
|
||||
reasonNotFound = "RESOURCE_NOT_FOUND"
|
||||
reasonConflict = "CONFLICT"
|
||||
reasonValidationFailed = "VALIDATION_FAILED"
|
||||
reasonInternal = "INTERNAL"
|
||||
)
|
||||
|
||||
@@ -32,44 +25,20 @@ type apiError struct {
|
||||
|
||||
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
|
||||
writeJSON(w, r, http.StatusOK, map[string]any{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
"request_id": requestID(r),
|
||||
"code": 0, "data": data, "request_id": requestID(r),
|
||||
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
|
||||
writeJSON(w, r, status, apiError{
|
||||
Code: code,
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
Details: details,
|
||||
RequestID: requestID(r),
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound), errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
||||
case errors.Is(err, ErrForbidden), errors.Is(err, store.ErrForbidden), errors.Is(err, auth.ErrForbidden):
|
||||
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict):
|
||||
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil)
|
||||
case errors.Is(err, store.ErrPolicy):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details,
|
||||
RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-IM-Schema", firstNonEmpty(r.Header.Get("X-IM-Schema"), "1"))
|
||||
if rid := requestID(r); rid != "" {
|
||||
w.Header().Set("X-Request-ID", rid)
|
||||
if requestID := requestID(r); requestID != "" {
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
@@ -79,20 +48,21 @@ func requestID(r *http.Request) string {
|
||||
return r.Header.Get("X-Request-ID")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return ""
|
||||
return true
|
||||
}
|
||||
|
||||
func firstNonNilInt64(values ...*int64) *int64 {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
func methodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) {
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{http.MethodPost}
|
||||
}
|
||||
return nil
|
||||
w.Header().Set("Allow", strings.Join(allowed, ", "))
|
||||
writeError(w, r, http.StatusMethodNotAllowed, 30005, reasonMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
HallID int64 `json:"hall_id"`
|
||||
}
|
||||
|
||||
func (a *API) taskHalls(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Halls(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskEligibility(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hallID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("hall_id")), 10, 64)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, tasks.ErrHallIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Eligibility(r.Context(), principal.TenantID, principal.MemberID, hallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Stats(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body taskClaimRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Claim(r.Context(), principal.TenantID, principal.MemberID, body.HallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Orders(r.Context(), principal.TenantID, principal.MemberID, page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrderResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/task-orders/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 || len(parts) > 2 || parts[0] == "" {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Order(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
if parts[1] != "submissions" {
|
||||
writeTaskError(w, r, store.ErrOrderNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var body tasks.SubmitInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Submit(r.Context(), principal.TenantID, principal.MemberID, orderID, body)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateTaskMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.tasks == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func optionalPositiveInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(name))
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
return parsed, err == nil && parsed > 0
|
||||
}
|
||||
|
||||
func writeTaskError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, tasks.ErrHallIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "HALL_ID_INVALID", "validation failed", map[string]any{"field": "hall_id"})
|
||||
case errors.Is(err, tasks.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, tasks.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, tasks.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, tasks.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, tasks.ErrEvidenceInvalid), errors.Is(err, store.ErrEvidenceInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "EVIDENCE_INVALID", "validation failed", map[string]any{"field": "evidence"})
|
||||
case errors.Is(err, tasks.ErrTaskDisabled), errors.Is(err, store.ErrTaskDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TASK_DISABLED", "task feature is disabled", nil)
|
||||
case errors.Is(err, tasks.ErrOutsideBusinessHour), errors.Is(err, store.ErrOutsideBusinessHours):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "OUTSIDE_BUSINESS_HOURS", "outside business hours", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrDailyLimitReached):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "DAILY_LIMIT_REACHED", "daily claim limit reached", nil)
|
||||
case errors.Is(err, store.ErrLevelNotEligible), errors.Is(err, store.ErrTaskHallNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "LEVEL_NOT_ELIGIBLE", "member level is not eligible", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound), errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrPendingOrderExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PENDING_ORDER_EXISTS", "unfinished order exists", nil)
|
||||
case errors.Is(err, store.ErrNoTaskAvailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "NO_TASK_AVAILABLE", "no task is currently available", nil)
|
||||
case errors.Is(err, store.ErrOrderNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ORDER_NOT_FOUND", "task order not found", nil)
|
||||
case errors.Is(err, store.ErrOrderState):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_STATE_INVALID", "task order state does not allow this operation", nil)
|
||||
case errors.Is(err, store.ErrOrderExpired):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_EXPIRED", "task order has expired", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/xtea"
|
||||
)
|
||||
|
||||
type userID int64
|
||||
|
||||
const defaultUIDKey = "dkXxWLwqUNRfYmdXRzOkZw=="
|
||||
|
||||
var (
|
||||
uidCodecMu sync.RWMutex
|
||||
uidCipher *xtea.Cipher
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := SetUIDKey(defaultUIDKey); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func SetUIDKey(encoded string) error {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid uid key: %w", err)
|
||||
}
|
||||
cipher, err := xtea.NewCipher(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid uid key: %w", err)
|
||||
}
|
||||
uidCodecMu.Lock()
|
||||
uidCipher = cipher
|
||||
uidCodecMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (id *userID) UnmarshalJSON(raw []byte) error {
|
||||
value, err := parseUserIDJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = userID(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type optionalUserID struct {
|
||||
value int64
|
||||
set bool
|
||||
}
|
||||
|
||||
func (id *optionalUserID) UnmarshalJSON(raw []byte) error {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return nil
|
||||
}
|
||||
value, err := parseUserIDJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id.set = true
|
||||
id.value = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (id optionalUserID) ptr() *int64 {
|
||||
if !id.set {
|
||||
return nil
|
||||
}
|
||||
return &id.value
|
||||
}
|
||||
|
||||
func parseUserIDJSON(raw []byte) (int64, error) {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return parseUserID(text)
|
||||
}
|
||||
return 0, fmt.Errorf("user id must be a string")
|
||||
}
|
||||
|
||||
func parseUserID(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("empty user id")
|
||||
}
|
||||
if strings.HasPrefix(value, "usr") {
|
||||
decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(strings.TrimPrefix(value, "usr"))
|
||||
if err != nil || len(decoded) < 8 {
|
||||
return 0, fmt.Errorf("invalid user id")
|
||||
}
|
||||
return decryptUID(decoded[:8]), nil
|
||||
}
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
func formatUserID(id int64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
encoded := encryptUID(id)
|
||||
return "usr" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encoded)
|
||||
}
|
||||
|
||||
func parseUserIDList(values []userID) []int64 {
|
||||
items := make([]int64, 0, len(values))
|
||||
seen := make(map[int64]bool, len(values))
|
||||
for _, value := range values {
|
||||
id := int64(value)
|
||||
if id == 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, id)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func decryptUID(encoded []byte) int64 {
|
||||
dst := make([]byte, 8)
|
||||
uidCodecMu.RLock()
|
||||
cipher := uidCipher
|
||||
uidCodecMu.RUnlock()
|
||||
cipher.Decrypt(dst, encoded[:8])
|
||||
return int64(binary.LittleEndian.Uint64(dst))
|
||||
}
|
||||
|
||||
func encryptUID(id int64) []byte {
|
||||
src := make([]byte, 8)
|
||||
dst := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(src, uint64(id))
|
||||
uidCodecMu.RLock()
|
||||
cipher := uidCipher
|
||||
uidCodecMu.RUnlock()
|
||||
cipher.Encrypt(dst, src)
|
||||
return dst
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserIDJSONAcceptsIMUserID(t *testing.T) {
|
||||
var body struct {
|
||||
RecorderUserID userID `json:"recorder_user_id"`
|
||||
AttendeeUserIDs []userID `json:"attendee_user_ids"`
|
||||
}
|
||||
raw := []byte(`{"recorder_user_id":"usrag7w9xJ6hto","attendee_user_ids":["usr1RqTFgHSenM"]}`)
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.RecorderUserID == 0 || len(body.AttendeeUserIDs) != 1 || body.AttendeeUserIDs[0] == 0 {
|
||||
t.Fatalf("decoded zero ids: %+v", body)
|
||||
}
|
||||
if got := formatUserID(int64(body.RecorderUserID)); got != "usrag7w9xJ6hto" {
|
||||
t.Fatalf("roundtrip recorder=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDCodecMatchesIMServer(t *testing.T) {
|
||||
const (
|
||||
publicID = "usr3rZjh8TD_34"
|
||||
dbID = int64(1574592702383656960)
|
||||
)
|
||||
got, err := parseUserID(publicID)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUserID: %v", err)
|
||||
}
|
||||
if got != dbID {
|
||||
t.Fatalf("parseUserID(%q)=%d, want %d", publicID, got, dbID)
|
||||
}
|
||||
if formatted := formatUserID(dbID); formatted != publicID {
|
||||
t.Fatalf("formatUserID(%d)=%q, want %q", dbID, formatted, publicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDJSONRejectsNumericID(t *testing.T) {
|
||||
var id userID
|
||||
if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil {
|
||||
t.Fatal("expected numeric user id to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package mall
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrCategoryIDInvalid = errors.New("category id invalid")
|
||||
ErrProductIDInvalid = errors.New("product id invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
package mall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
maxSummaryRunes = 120
|
||||
)
|
||||
|
||||
var (
|
||||
scriptBlockPattern = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
|
||||
styleBlockPattern = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
|
||||
htmlTagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error)
|
||||
ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
ProductNo string `json:"product_no"`
|
||||
Category Category `json:"category"`
|
||||
Name string `json:"name"`
|
||||
ShopName string `json:"shop_name"`
|
||||
PriceAmount int64 `json:"price_amount"`
|
||||
Currency string `json:"currency"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Summary string `json:"summary"`
|
||||
ClaimedCount int64 `json:"claimed_count"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
AvailableActions []string `json:"available_actions"`
|
||||
PaymentType string `json:"payment_type,omitempty"`
|
||||
DetailText string `json:"detail_text,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProductPage struct {
|
||||
Items []Product `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Products(ctx context.Context, tenantID, categoryID int64, page, pageSize int) (*ProductPage, error) {
|
||||
if categoryID < 0 {
|
||||
return nil, ErrCategoryIDInvalid
|
||||
}
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
rows, err := s.repository.ManualProducts(ctx, tenantID, categoryID, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Product, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, productFromStore(row, false))
|
||||
}
|
||||
return &ProductPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Product(ctx context.Context, tenantID, productID int64) (*Product, error) {
|
||||
if productID <= 0 {
|
||||
return nil, ErrProductIDInvalid
|
||||
}
|
||||
row, err := s.repository.ManualProduct(ctx, tenantID, productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := productFromStore(*row, true)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func productFromStore(row store.ManualProduct, detail bool) Product {
|
||||
detailText := plainText(row.DetailHTML)
|
||||
result := Product{ID: row.ID, ProductNo: row.ProductNo,
|
||||
Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName},
|
||||
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
|
||||
CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes),
|
||||
ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended,
|
||||
AvailableActions: []string{"contact_support"}}
|
||||
if detail {
|
||||
result.PaymentType = row.PaymentType
|
||||
result.DetailText = detailText
|
||||
result.CreatedAt = row.CreatedAt
|
||||
result.UpdatedAt = row.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func plainText(raw string) string {
|
||||
raw = scriptBlockPattern.ReplaceAllString(raw, " ")
|
||||
raw = styleBlockPattern.ReplaceAllString(raw, " ")
|
||||
withoutTags := htmlTagPattern.ReplaceAllString(raw, " ")
|
||||
return strings.Join(strings.Fields(html.UnescapeString(withoutTags)), " ")
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
if utf8.RuneCountInString(value) <= limit {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
func safeHTTPSURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
items []store.ManualProduct
|
||||
detail *store.ManualProduct
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) {
|
||||
return &store.ManualProductPage{Items: f.items, Total: int64(len(f.items))}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) {
|
||||
return f.detail, nil
|
||||
}
|
||||
|
||||
func TestProductsMapsSafeCard(t *testing.T) {
|
||||
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
|
||||
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
|
||||
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
|
||||
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true}}})
|
||||
|
||||
result, err := service.Products(context.Background(), 0, 0, 1, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" ||
|
||||
len(result.Items[0].AvailableActions) != 1 || result.Items[0].AvailableActions[0] != "contact_support" {
|
||||
t.Fatalf("unexpected product: %#v", result.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductRejectsUnsafeCoverAndReturnsPlainText(t *testing.T) {
|
||||
service := New(&fakeRepository{detail: &store.ManualProduct{ID: 4, Name: "商品",
|
||||
CoverURL: "javascript:alert(1)", DetailHTML: "<p>安全说明</p>"}})
|
||||
|
||||
result, err := service.Product(context.Background(), 0, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CoverURL != "" || result.DetailText != "安全说明" {
|
||||
t.Fatalf("unexpected detail: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductsValidatesPagination(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
if _, err := service.Products(context.Background(), 0, 0, -1, 20); !errors.Is(err, ErrPageInvalid) {
|
||||
t.Fatalf("expected page error, got %v", err)
|
||||
}
|
||||
if _, err := service.Products(context.Background(), 0, -1, 1, 20); !errors.Is(err, ErrCategoryIDInvalid) {
|
||||
t.Fatalf("expected category error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package managerconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
)
|
||||
|
||||
const maxResponseBody = 1 << 20
|
||||
|
||||
type Client struct {
|
||||
cfg config.ManagerConfig
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func ResolveUIDKey(ctx context.Context, cfg config.ManagerConfig, fallback string) string {
|
||||
key, err := New(cfg).UIDKey(ctx)
|
||||
if err != nil || strings.TrimSpace(key) == "" {
|
||||
return fallback
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func ResolveAIConfig(ctx context.Context, manager config.ManagerConfig, fallback config.AIConfig) config.AIConfig {
|
||||
values, err := New(manager).ThirdPartyConfig(ctx, "im_ai",
|
||||
"provider",
|
||||
"deepseek_base_url",
|
||||
"deepseek_api_key",
|
||||
"deepseek_default_model",
|
||||
"deepseek_complex_model",
|
||||
)
|
||||
if err != nil || len(values) == 0 {
|
||||
return fallback
|
||||
}
|
||||
resolved := fallback
|
||||
if value := strings.TrimSpace(values["provider"]); value != "" {
|
||||
resolved.Provider = value
|
||||
}
|
||||
if value := strings.TrimSpace(values["deepseek_base_url"]); value != "" {
|
||||
resolved.DeepSeek.BaseURL = value
|
||||
}
|
||||
if value := strings.TrimSpace(values["deepseek_api_key"]); value != "" {
|
||||
resolved.DeepSeek.APIKey = value
|
||||
}
|
||||
if value := strings.TrimSpace(values["deepseek_default_model"]); value != "" {
|
||||
resolved.DefaultModel = value
|
||||
}
|
||||
if value := strings.TrimSpace(values["deepseek_complex_model"]); value != "" {
|
||||
resolved.ComplexModel = value
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func New(cfg config.ManagerConfig) *Client {
|
||||
timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) UIDKey(ctx context.Context) (string, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
return "", fmt.Errorf("manager base url is empty")
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Secret) == "" {
|
||||
return "", fmt.Errorf("manager secret is empty")
|
||||
}
|
||||
keys := cleanList(c.cfg.UIDKeyKeys)
|
||||
if len(keys) == 0 {
|
||||
keys = []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}
|
||||
}
|
||||
group := strings.TrimSpace(c.cfg.UIDKeyGroup)
|
||||
if group == "" {
|
||||
group = "im"
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("keys", strings.Join(keys, ","))
|
||||
path := "/internal/im-config/third-party/" + url.PathEscape(group) + "?" + values.Encode()
|
||||
req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("manager status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
valuesMap, err := parseValues(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(valuesMap[key]); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("uid key is empty")
|
||||
}
|
||||
|
||||
func (c *Client) ThirdPartyConfig(ctx context.Context, group string, keys ...string) (map[string]string, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("manager base url is empty")
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Secret) == "" {
|
||||
return nil, fmt.Errorf("manager secret is empty")
|
||||
}
|
||||
values := url.Values{}
|
||||
if keys := cleanList(keys); len(keys) > 0 {
|
||||
values.Set("keys", strings.Join(keys, ","))
|
||||
}
|
||||
path := "/internal/im-config/third-party/" + url.PathEscape(strings.TrimSpace(group))
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodGet, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("manager status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseValues(body)
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method string, rawURL string, body []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||
nonce, err := randomNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
signingText := strings.Join([]string{
|
||||
method,
|
||||
req.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestamp,
|
||||
nonce,
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(c.cfg.Secret))
|
||||
_, _ = mac.Write([]byte(signingText))
|
||||
serviceName := strings.TrimSpace(c.cfg.ServiceName)
|
||||
if serviceName == "" {
|
||||
serviceName = "im-workbench"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Service-Name", serviceName)
|
||||
req.Header.Set("X-Timestamp", timestamp)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func parseValues(body []byte) (map[string]string, error) {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 {
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("manager code %d", envelope.Code)
|
||||
}
|
||||
body = envelope.Data
|
||||
}
|
||||
var payload struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.Values == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return payload.Values, nil
|
||||
}
|
||||
|
||||
func cleanList(values []string) []string {
|
||||
items := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
items = append(items, value)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func randomNonce() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package managerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
)
|
||||
|
||||
func TestResolveUIDKeyPrefersManager(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
const managerKey = "manager-key"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/internal/im-config/third-party/im" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("keys") != "uid_encryption_key,UID_ENCRYPTION_KEY,uid_key" {
|
||||
t.Fatalf("unexpected keys %s", r.URL.Query().Get("keys"))
|
||||
}
|
||||
if r.Header.Get("X-Service-Name") != "im-workbench" {
|
||||
t.Fatalf("unexpected service %s", r.Header.Get("X-Service-Name"))
|
||||
}
|
||||
if !validSignature(t, r, secret) {
|
||||
t.Fatal("invalid signature")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"values":{"uid_encryption_key":"manager-key"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
got := ResolveUIDKey(context.Background(), config.ManagerConfig{
|
||||
BaseURL: server.URL,
|
||||
ServiceName: "im-workbench",
|
||||
Secret: secret,
|
||||
UIDKeyGroup: "im",
|
||||
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
||||
}, "fallback-key")
|
||||
if got != managerKey {
|
||||
t.Fatalf("ResolveUIDKey() = %q, want %q", got, managerKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUIDKeyFallsBack(t *testing.T) {
|
||||
got := ResolveUIDKey(context.Background(), config.ManagerConfig{}, "fallback-key")
|
||||
if got != "fallback-key" {
|
||||
t.Fatalf("ResolveUIDKey() = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValuesAcceptsUnwrappedResponse(t *testing.T) {
|
||||
values, err := parseValues([]byte(`{"values":{"UID_ENCRYPTION_KEY":"key"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["UID_ENCRYPTION_KEY"] != "key" {
|
||||
t.Fatalf("unexpected values %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func validSignature(t *testing.T, r *http.Request, secret string) bool {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
signingText := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
r.Header.Get("X-Timestamp"),
|
||||
r.Header.Get("X-Nonce"),
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(signingText))
|
||||
return hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil))))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package portal
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPositionInvalid = errors.New("banner position invalid")
|
||||
ErrContentKeyInvalid = errors.New("content key invalid")
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
positionPattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
|
||||
contentKeyPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
ActiveBanners(context.Context, int64, string) ([]store.Banner, error)
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Features struct {
|
||||
TaskEnabled bool `json:"task_enabled"`
|
||||
RechargeEnabled bool `json:"recharge_enabled"`
|
||||
WithdrawalEnabled bool `json:"withdrawal_enabled"`
|
||||
ManualMallEnabled bool `json:"manual_mall_enabled"`
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
AltText string `json:"alt_text"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetValue string `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
|
||||
type Home struct {
|
||||
Profile HomeProfile `json:"profile"`
|
||||
Wallet accounts.ProfileWallet `json:"wallet"`
|
||||
Features Features `json:"features"`
|
||||
Banners []Banner `json:"banners"`
|
||||
Notices []Notice `json:"notices"`
|
||||
TaskSummary store.TaskSummary `json:"task_summary"`
|
||||
}
|
||||
|
||||
type HomeProfile struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
LevelName string `json:"level_name"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ContentKey string `json:"content_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
BodyText string `json:"body_text"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
Version int `json:"version"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
type MemberLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
UpgradeAmount int64 `json:"upgrade_amount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
DailyOrderLimit int `json:"daily_order_limit"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
DailyWithdrawLimit int `json:"daily_withdraw_limit"`
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Home(ctx context.Context, tenantID, memberID int64, profile *accounts.Profile) (*Home, error) {
|
||||
config, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banners, err := s.Banners(ctx, tenantID, "home_top")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noticeRows, err := s.repository.ActiveMemberNotices(ctx, tenantID, memberID, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.repository.MemberTaskSummary(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notices := make([]Notice, 0, len(noticeRows))
|
||||
for _, row := range noticeRows {
|
||||
notices = append(notices, Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt})
|
||||
}
|
||||
return &Home{
|
||||
Profile: HomeProfile{Username: profile.Username, AvatarURL: profile.AvatarURL, LevelName: profile.Level.Name},
|
||||
Wallet: profile.Wallet, Features: parseFeatures(config), Banners: banners, Notices: notices,
|
||||
TaskSummary: *summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Banners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
|
||||
position = strings.ToLower(strings.TrimSpace(position))
|
||||
if !positionPattern.MatchString(position) {
|
||||
return nil, ErrPositionInvalid
|
||||
}
|
||||
rows, err := s.repository.ActiveBanners(ctx, tenantID, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Banner, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
targetType, targetValue := bannerTarget(row.TargetType, row.TargetValue)
|
||||
items = append(items, Banner{ID: row.ID, ImageURL: row.ImageURL, AltText: row.AltText,
|
||||
TargetType: targetType, TargetValue: targetValue})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
return nil, ErrContentKeyInvalid
|
||||
}
|
||||
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assetIDs := make([]int64, 0)
|
||||
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
||||
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
|
||||
}
|
||||
return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title,
|
||||
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
|
||||
rows, err := s.repository.ActiveMemberLevels(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]MemberLevel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rules := row.RuleJSON
|
||||
if len(rules) == 0 {
|
||||
rules = json.RawMessage(`{}`)
|
||||
}
|
||||
items = append(items, MemberLevel{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
||||
UpgradeAmount: row.UpgradeAmount, GiftAmount: row.GiftAmount, DailyOrderLimit: row.DailyOrderLimit,
|
||||
CommissionRate: row.CommissionRate, DailyWithdrawLimit: row.DailyWithdrawLimit,
|
||||
Rules: rules, Version: row.Version})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseFeatures(raw json.RawMessage) Features {
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
return Features{}
|
||||
}
|
||||
return Features{
|
||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
}
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
if nested, ok := config["features"].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, found := nested[key]; found {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func bannerTarget(kind int, value string) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch kind {
|
||||
case 2:
|
||||
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
|
||||
return "internal_route", value
|
||||
}
|
||||
case 3:
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && parsed.Scheme == "https" && parsed.Host != "" {
|
||||
return "external_url", value
|
||||
}
|
||||
}
|
||||
return "none", ""
|
||||
}
|
||||
|
||||
func noticeSourceName(source int) string {
|
||||
if source == 2 {
|
||||
return "operational_simulation"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
banners []store.Banner
|
||||
content *store.Content
|
||||
levels []store.MemberLevel
|
||||
notices []store.MemberNotice
|
||||
summary *store.TaskSummary
|
||||
}
|
||||
|
||||
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return r.config, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveBanners(context.Context, int64, string) ([]store.Banner, error) {
|
||||
return r.banners, nil
|
||||
}
|
||||
func (r fakeRepository) PublishedContent(context.Context, int64, string) (*store.Content, error) {
|
||||
if r.content == nil {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
return r.content, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) {
|
||||
return r.levels, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
|
||||
return r.summary, nil
|
||||
}
|
||||
|
||||
func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
||||
features := parseFeatures(json.RawMessage(`{
|
||||
"features":{"task_enabled":true,"rechargeEnabled":1},
|
||||
"WITHDRAWAL":"on","SHOP_STATUS":"false"
|
||||
}`))
|
||||
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || features.ManualMallEnabled {
|
||||
t.Fatalf("unexpected features: %+v", features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersSanitizeTargets(t *testing.T) {
|
||||
service := New(fakeRepository{banners: []store.Banner{
|
||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||
{ID: 2, TargetType: 2, TargetValue: "//evil.example"},
|
||||
{ID: 3, TargetType: 3, TargetValue: "https://example.com/path"},
|
||||
{ID: 4, TargetType: 3, TargetValue: "javascript:alert(1)"},
|
||||
}})
|
||||
items, err := service.Banners(context.Background(), 0, "home_top")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTypes := []string{"internal_route", "none", "external_url", "none"}
|
||||
for i, want := range wantTypes {
|
||||
if items[i].TargetType != want {
|
||||
t.Errorf("item %d target type = %q, want %q", i, items[i].TargetType, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersRejectInvalidPosition(t *testing.T) {
|
||||
service := New(fakeRepository{})
|
||||
if _, err := service.Banners(context.Background(), 0, "../home"); !errors.Is(err, ErrPositionInvalid) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrPositionInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentMapsAssets(t *testing.T) {
|
||||
service := New(fakeRepository{content: &store.Content{
|
||||
ContentKey: "privacy_policy", Title: "Policy", AssetIDs: sql.NullString{String: `[1,2]`, Valid: true},
|
||||
}})
|
||||
content, err := service.Content(context.Background(), 0, "privacy_policy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(content.AssetIDs) != 2 || content.AssetIDs[0] != 1 || content.AssetIDs[1] != 2 {
|
||||
t.Fatalf("unexpected asset IDs: %v", content.AssetIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberLevelsAlwaysReturnJSONObjectRules(t *testing.T) {
|
||||
service := New(fakeRepository{levels: []store.MemberLevel{{ID: 1, Code: "VIP1", Name: "Normal"}}})
|
||||
levels, err := service.MemberLevels(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(levels[0].Rules) != `{}` {
|
||||
t.Fatalf("rules = %s, want {}", levels[0].Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
|
||||
service := New(fakeRepository{
|
||||
config: json.RawMessage(`{"features":{"task_enabled":true}}`),
|
||||
summary: &store.TaskSummary{TodayCount: 2, TodayCommissionAmount: 150},
|
||||
})
|
||||
home, err := service.Home(context.Background(), 0, 7, &accounts.Profile{
|
||||
Username: "alice", Level: accounts.ProfileLevel{Name: "VIP1"},
|
||||
Wallet: accounts.ProfileWallet{Currency: "CNY"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, field := range []string{`"today_count":2`, `"today_commission_amount":150`, `"task_enabled":true`} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Errorf("home JSON %s does not contain %s", text, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
memberStatusActive = 1
|
||||
memberCategoryReal = 1
|
||||
)
|
||||
|
||||
type AccountRegistration struct {
|
||||
TenantID int64
|
||||
MemberNo string
|
||||
Username string
|
||||
MobileCipher string
|
||||
MobileHash string
|
||||
MobileMask string
|
||||
PasswordHash string
|
||||
ReferralCode string
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
TenantID int64 `db:"tenant_id"`
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
MobileHash string `db:"mobile_hash"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
}
|
||||
|
||||
func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration) (*Account, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
parentID, teamLevel, tree, err := referralParent(ctx, tx, input.TenantID, input.ReferralCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levelID, err := defaultMemberLevel(ctx, tx, input.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member
|
||||
(tenant_id,member_no,username,mobile_cipher,mobile_hash,mobile_mask,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)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.MobileCipher, input.MobileHash,
|
||||
input.MobileMask, input.PasswordHash, levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrAccountExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
memberID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
|
||||
(tenant_id,member_id,created_by,updated_by) VALUES(?,?,0,0)`, input.TenantID, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_wallet_account
|
||||
(tenant_id,member_id,currency,available_amount,frozen_amount,total_income,total_expense,account_status,version)
|
||||
VALUES(?,?,'CNY',0,0,0,0,1,0)`, input.TenantID, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo,
|
||||
Username: input.Username, MobileMask: input.MobileMask, MobileHash: input.MobileHash,
|
||||
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByMobileHash(ctx context.Context, tenantID int64, mobileHash string) (*Account, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
return accountByMobileHash(ctx, s.db, tenantID, mobileHash)
|
||||
}
|
||||
|
||||
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET last_login_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND id=? AND deleted_at IS NULL`, time.Now().UTC(), time.Now().UTC(), tenantID, memberID)
|
||||
return err
|
||||
}
|
||||
|
||||
type queryer interface {
|
||||
GetContext(context.Context, any, string, ...any) error
|
||||
}
|
||||
|
||||
func accountByMobileHash(ctx context.Context, db queryer, tenantID int64, mobileHash string) (*Account, error) {
|
||||
var row Account
|
||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile_mask,
|
||||
mobile_hash, password_hash, invite_code, member_status, risk_status AS member_category
|
||||
FROM hg_app_member WHERE tenant_id=? AND mobile_hash=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobileHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.MemberStatus != memberStatusActive {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return 0, 1, "", nil
|
||||
}
|
||||
var parent struct {
|
||||
ID int64 `db:"id"`
|
||||
Level int `db:"level"`
|
||||
Tree string `db:"tree"`
|
||||
}
|
||||
err := tx.GetContext(ctx, &parent, `SELECT id,level,tree FROM hg_app_member
|
||||
WHERE tenant_id=? AND invite_code=? AND member_status=1
|
||||
AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", ErrReferralInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
tree := strings.Trim(strings.TrimSpace(parent.Tree)+","+formatInt64(parent.ID), ",")
|
||||
if len(tree) > 512 {
|
||||
return 0, 0, "", ErrReferralInvalid
|
||||
}
|
||||
return parent.ID, parent.Level + 1, tree, nil
|
||||
}
|
||||
|
||||
func defaultMemberLevel(ctx context.Context, tx *sqlx.Tx, tenantID int64) (int64, error) {
|
||||
var id int64
|
||||
err := tx.GetContext(ctx, &id, `SELECT id FROM hg_app_member_level
|
||||
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC LIMIT 1`, tenantID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func formatInt64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AdminWorkbenchModule struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
IconFileID string `db:"icon_file_id"`
|
||||
Route string `db:"route"`
|
||||
SortOrder int `db:"sort_order"`
|
||||
UnreadKey string `db:"unread_key"`
|
||||
Enabled int `db:"enabled"`
|
||||
Permission string `db:"permission"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AdminWorkbenchModuleInput struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
IconFileID string
|
||||
Route string
|
||||
SortOrder int
|
||||
UnreadKey string
|
||||
Enabled int
|
||||
Permission string
|
||||
}
|
||||
|
||||
type AdminWorkbenchReorderItem struct {
|
||||
Code string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type AdminMailboxConfig struct {
|
||||
ID int64 `db:"id"`
|
||||
Enabled int `db:"enabled"`
|
||||
AllowAnonymous int `db:"allow_anonymous"`
|
||||
RequiredFields string `db:"required_fields"`
|
||||
MaxLength int `db:"max_length"`
|
||||
AttachmentPolicy string `db:"attachment_policy"`
|
||||
NoticeTitle string `db:"notice_title"`
|
||||
NoticeContent string `db:"notice_content"`
|
||||
NoticeUpdatedAt time.Time `db:"notice_updated_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AdminFeedbackConfig struct {
|
||||
ID int64 `db:"id"`
|
||||
Categories string `db:"categories"`
|
||||
MaxLength int `db:"max_length"`
|
||||
AttachmentPolicy string `db:"attachment_policy"`
|
||||
AllowContact int `db:"allow_contact"`
|
||||
DiagnosticsNotice string `db:"diagnostics_notice"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) AdminWorkbenchModules(ctx context.Context, page, perPage int, code, name string, enabled *int) ([]AdminWorkbenchModule, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
where := []string{"tenant_id=?"}
|
||||
args := []any{s.tenant()}
|
||||
if code = strings.TrimSpace(code); code != "" {
|
||||
where = append(where, "code LIKE ?")
|
||||
args = append(args, "%"+code+"%")
|
||||
}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
where = append(where, "name LIKE ?")
|
||||
args = append(args, "%"+name+"%")
|
||||
}
|
||||
if enabled != nil {
|
||||
where = append(where, "enabled=?")
|
||||
args = append(args, *enabled)
|
||||
}
|
||||
whereSQL := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_workbench_apps WHERE "+whereSQL, args...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
queryArgs := append([]any{}, args...)
|
||||
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||
var items []AdminWorkbenchModule
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at
|
||||
FROM im_workbench_apps WHERE `+whereSQL+` ORDER BY sort_order ASC,id ASC LIMIT ?,?`, queryArgs...)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAdminWorkbenchModule(ctx context.Context, in AdminWorkbenchModuleInput) (*AdminWorkbenchModule, error) {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
in.Code = strings.TrimSpace(in.Code)
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.IconFileID = strings.TrimSpace(in.IconFileID)
|
||||
in.Route = strings.TrimSpace(in.Route)
|
||||
in.UnreadKey = strings.TrimSpace(in.UnreadKey)
|
||||
in.Permission = strings.TrimSpace(in.Permission)
|
||||
if in.Permission == "" {
|
||||
in.Permission = "enabled"
|
||||
}
|
||||
if in.ID > 0 {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET code=?,name=?,icon_file_id=?,route=?,sort_order=?,unread_key=?,enabled=?,permission=?,updated_at=? WHERE tenant_id=? AND id=?`,
|
||||
in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, s.tenant(), in.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.adminWorkbenchModuleByID(ctx, in.ID)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO im_workbench_apps(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.adminWorkbenchModuleByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) DisableAdminWorkbenchModule(ctx context.Context, id int64, code string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
var res sql.Result
|
||||
var err error
|
||||
if id > 0 {
|
||||
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND id=?`, now, s.tenant(), id)
|
||||
} else {
|
||||
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE tenant_id=? AND code=?`, now, s.tenant(), strings.TrimSpace(code))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminWorkbenchReorderItem) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
for _, item := range items {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_workbench_apps SET sort_order=?,updated_at=? WHERE tenant_id=? AND code=?`, item.SortOrder, now, s.tenant(), strings.TrimSpace(item.Code)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) AdminOrganizationDepartments(ctx context.Context, page, perPage int, parentID, name, state string) ([]OrganizationDepartment, int, error) {
|
||||
page, perPage = normalizeAdminPage(page, perPage)
|
||||
where := []string{"tenant_id=?"}
|
||||
args := []any{s.tenant()}
|
||||
if parentID = strings.TrimSpace(parentID); parentID != "" {
|
||||
where = append(where, "parent_id=?")
|
||||
args = append(args, parentID)
|
||||
}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
where = append(where, "name LIKE ?")
|
||||
args = append(args, "%"+name+"%")
|
||||
}
|
||||
if state = strings.TrimSpace(state); state != "" {
|
||||
where = append(where, "state=?")
|
||||
args = append(args, state)
|
||||
}
|
||||
whereSQL := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_departments WHERE "+whereSQL, args...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
queryArgs := append([]any{}, args...)
|
||||
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||
var items []OrganizationDepartment
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT d.department_id,d.name,d.parent_id,
|
||||
(SELECT COUNT(*) FROM im_org_departments c WHERE c.tenant_id=d.tenant_id AND c.parent_id=d.department_id AND c.state='active') AS child_count,
|
||||
(SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count,
|
||||
d.version
|
||||
FROM im_org_departments d WHERE `+whereSQL+` ORDER BY d.parent_id ASC,d.version DESC,d.department_id ASC LIMIT ?,?`, queryArgs...)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int, departmentID, keyword, state string, userID int64) ([]OrganizationMember, int, error) {
|
||||
page, perPage = normalizeAdminPage(page, perPage)
|
||||
where := []string{"tenant_id=?"}
|
||||
args := []any{s.tenant()}
|
||||
if departmentID = strings.TrimSpace(departmentID); departmentID != "" {
|
||||
where = append(where, "department_id=?")
|
||||
args = append(args, departmentID)
|
||||
}
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ? OR email LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if state = strings.TrimSpace(state); state != "" {
|
||||
where = append(where, "employment_state=?")
|
||||
args = append(args, state)
|
||||
}
|
||||
if userID > 0 {
|
||||
where = append(where, "user_id=?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
whereSQL := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_members WHERE "+whereSQL, args...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
queryArgs := append([]any{}, args...)
|
||||
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||
var items []OrganizationMember
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email,
|
||||
IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version
|
||||
FROM im_org_members WHERE `+whereSQL+` ORDER BY department_id ASC,user_id ASC LIMIT ?,?`, queryArgs...)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) {
|
||||
var cfg AdminMailboxConfig
|
||||
if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
|
||||
if errorsIsNoRows(err) {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
return &AdminMailboxConfig{Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", NoticeUpdatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`,
|
||||
s.tenant(), cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) {
|
||||
var cfg AdminFeedbackConfig
|
||||
if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
|
||||
if errorsIsNoRows(err) {
|
||||
return &AdminFeedbackConfig{Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 2000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`,
|
||||
s.tenant(), defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) adminWorkbenchModuleByID(ctx context.Context, id int64) (*AdminWorkbenchModule, error) {
|
||||
var item AdminWorkbenchModule
|
||||
if err := s.db.GetContext(ctx, &item, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at FROM im_workbench_apps WHERE tenant_id=? AND id=?`, s.tenant(), id); err != nil {
|
||||
if errorsIsNoRows(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func errorsIsNoRows(err error) bool {
|
||||
return err == sql.ErrNoRows
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeAdminPage(page, perPage int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AICapability struct {
|
||||
Feature string `db:"feature"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Model string `db:"model"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
ConcurrencyLimit int `db:"concurrency_limit"`
|
||||
MaxInputChars int `db:"max_input_chars"`
|
||||
MaxFileSizeMB int `db:"max_file_size_mb"`
|
||||
ConfigJSON string `db:"config_json"`
|
||||
}
|
||||
|
||||
type AITask struct {
|
||||
TaskID string `db:"task_id"`
|
||||
TenantID int64 `db:"tenant_id"`
|
||||
TenantCode string `db:"tenant_code"`
|
||||
UserID string `db:"user_id"`
|
||||
Feature string `db:"feature"`
|
||||
Status string `db:"status"`
|
||||
Progress int `db:"progress"`
|
||||
SourceType string `db:"source_type"`
|
||||
SourceRefID string `db:"source_ref_id"`
|
||||
InputHash string `db:"input_hash"`
|
||||
InputSnapshot sql.NullString `db:"input_snapshot"`
|
||||
PromptVersion string `db:"prompt_version"`
|
||||
Model string `db:"model"`
|
||||
ResultRefID string `db:"result_ref_id"`
|
||||
ResultJSON sql.NullString `db:"result_json"`
|
||||
ErrorReason string `db:"error_reason"`
|
||||
ErrorMessage string `db:"error_message"`
|
||||
RetryCount int `db:"retry_count"`
|
||||
StartedAt *time.Time `db:"started_at"`
|
||||
FinishedAt *time.Time `db:"finished_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AIRecord struct {
|
||||
RecordID string `db:"record_id"`
|
||||
TenantCode string `db:"tenant_code"`
|
||||
UserID string `db:"user_id"`
|
||||
RecordType string `db:"record_type"`
|
||||
Title string `db:"title"`
|
||||
Summary string `db:"summary"`
|
||||
Status string `db:"status"`
|
||||
DetailID string `db:"detail_id"`
|
||||
TaskID string `db:"task_id"`
|
||||
FileName string `db:"file_name"`
|
||||
FileType string `db:"file_type"`
|
||||
FileSize int64 `db:"file_size"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
DeletedAt *time.Time `db:"deleted_at"`
|
||||
}
|
||||
|
||||
type AIDocument struct {
|
||||
RecordID string `db:"record_id"`
|
||||
UserID string `db:"user_id"`
|
||||
DocumentType string `db:"document_type"`
|
||||
Title string `db:"title"`
|
||||
ContentMarkdown string `db:"content_markdown"`
|
||||
ContentPlain string `db:"content_plain"`
|
||||
SectionsJSON string `db:"sections_json"`
|
||||
SourceExcerpt string `db:"source_excerpt"`
|
||||
PromptVersion string `db:"prompt_version"`
|
||||
Model string `db:"model"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AITableAnalysis struct {
|
||||
RecordID string `db:"record_id"`
|
||||
UserID string `db:"user_id"`
|
||||
FileID string `db:"file_id"`
|
||||
FileName string `db:"file_name"`
|
||||
FileType string `db:"file_type"`
|
||||
FileSize int64 `db:"file_size"`
|
||||
Topic string `db:"topic"`
|
||||
MetricsJSON string `db:"metrics_json"`
|
||||
PreviewJSON string `db:"preview_json"`
|
||||
AlertsJSON string `db:"alerts_json"`
|
||||
Interpretation string `db:"interpretation"`
|
||||
ExportFileID string `db:"export_file_id"`
|
||||
PromptVersion string `db:"prompt_version"`
|
||||
Model string `db:"model"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AIMeetingSummary struct {
|
||||
SummaryID string `db:"summary_id"`
|
||||
MeetingID string `db:"meeting_id"`
|
||||
UserID string `db:"user_id"`
|
||||
TaskID string `db:"task_id"`
|
||||
ConclusionsJSON string `db:"conclusions_json"`
|
||||
DecisionsJSON string `db:"decisions_json"`
|
||||
ActionItemsJSON string `db:"action_items_json"`
|
||||
SourceFileIDsJSON string `db:"source_file_ids_json"`
|
||||
PromptVersion string `db:"prompt_version"`
|
||||
Model string `db:"model"`
|
||||
EditedBy string `db:"edited_by"`
|
||||
EditedAt *time.Time `db:"edited_at"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type AIUsageLog struct {
|
||||
TenantCode string
|
||||
UserID string
|
||||
Feature string
|
||||
TaskID string
|
||||
Provider string
|
||||
Model string
|
||||
ProviderRequestID string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
LatencyMS int
|
||||
Status string
|
||||
ErrorReason string
|
||||
InputHash string
|
||||
OutputHash string
|
||||
}
|
||||
|
||||
type AIFileMetadata struct {
|
||||
MIMEType string
|
||||
FileSize int64
|
||||
FileETag string
|
||||
Location string
|
||||
}
|
||||
|
||||
type CompletedFileUpload struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
MIMEType string
|
||||
Size int64
|
||||
ETag string
|
||||
Location string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) AICapabilities(ctx context.Context) ([]AICapability, error) {
|
||||
var items []AICapability
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit,
|
||||
max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json
|
||||
FROM wb_ai_capability_config WHERE tenant_id=? ORDER BY feature ASC`, s.tenant())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return items, nil
|
||||
}
|
||||
err = s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit,
|
||||
max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json
|
||||
FROM wb_ai_capability_config WHERE tenant_id=0 ORDER BY feature ASC`)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (s *Store) AIFileMetadata(ctx context.Context, fileID string) AIFileMetadata {
|
||||
meta := s.fileUploadMetadata(ctx, strings.TrimSpace(fileID))
|
||||
return AIFileMetadata{
|
||||
MIMEType: meta.MIMEType,
|
||||
FileSize: meta.FileSize,
|
||||
FileETag: meta.FileETag,
|
||||
Location: meta.Location,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateAITask(ctx context.Context, task AITask, result any) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
resultJSON, err := marshalNullable(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var inputSnapshot any
|
||||
if task.InputSnapshot.Valid {
|
||||
inputSnapshot = task.InputSnapshot.String
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO wb_ai_task
|
||||
(task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,input_hash,
|
||||
input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count,
|
||||
started_at,finished_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?)`,
|
||||
task.TaskID, s.tenant(), task.TenantCode, task.UserID, task.Feature, task.Status, task.Progress,
|
||||
task.SourceType, task.SourceRefID, task.InputHash, inputSnapshot, task.PromptVersion, task.Model, task.ResultRefID,
|
||||
resultJSON, task.ErrorReason, task.ErrorMessage, task.StartedAt, task.FinishedAt, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimQueuedAITask(ctx context.Context) (*AITask, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var task AITask
|
||||
err = tx.GetContext(ctx, &task, `SELECT task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,
|
||||
input_hash,input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count,
|
||||
started_at,finished_at,created_at,updated_at
|
||||
FROM wb_ai_task
|
||||
WHERE status='queued' AND feature IN ('smart_document','smart_table','meeting_summary')
|
||||
ORDER BY created_at ASC,id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := tx.ExecContext(ctx, `UPDATE wb_ai_task SET status='processing',progress=?,started_at=COALESCE(started_at,?),updated_at=?
|
||||
WHERE tenant_id=? AND task_id=? AND status='queued'`, maxInt(task.Progress, 10), now, now, task.TenantID, task.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Status = "processing"
|
||||
task.Progress = maxInt(task.Progress, 10)
|
||||
task.StartedAt = &now
|
||||
task.UpdatedAt = now
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
resultJSON, err := marshalNullable(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?,
|
||||
error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=?`,
|
||||
status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) (bool, error) {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
resultJSON, err := marshalNullable(result)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?,
|
||||
error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=? AND status='processing'`,
|
||||
status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelAITask(ctx context.Context, userID int64, taskID string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status='canceled',progress=100,finished_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND task_id=? AND status IN ('queued','processing')`,
|
||||
now, now, s.tenant(), formatInt64(userID), taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AITask(ctx context.Context, userID int64, taskID string) (*AITask, error) {
|
||||
var task AITask
|
||||
err := s.db.GetContext(ctx, &task, `SELECT task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,
|
||||
input_hash,input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count,
|
||||
started_at,finished_at,created_at,updated_at
|
||||
FROM wb_ai_task WHERE tenant_id=? AND user_id=? AND task_id=?`, s.tenant(), formatInt64(userID), taskID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertAIUsageLog(ctx context.Context, log AIUsageLog) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_usage_log
|
||||
(tenant_id,tenant_code,user_id,feature,task_id,provider,model,provider_request_id,prompt_tokens,
|
||||
completion_tokens,total_tokens,latency_ms,status,error_reason,input_hash,output_hash,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
s.tenant(), log.TenantCode, log.UserID, log.Feature, log.TaskID, log.Provider, log.Model, log.ProviderRequestID,
|
||||
log.PromptTokens, log.CompletionTokens, log.TotalTokens, log.LatencyMS, log.Status, log.ErrorReason,
|
||||
log.InputHash, log.OutputHash, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SaveAIDocument(ctx context.Context, tenantCode string, userID int64, record AIRecord, doc AIDocument) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record
|
||||
(record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary,
|
||||
record.Status, doc.RecordID, record.TaskID, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_document
|
||||
(record_id,tenant_id,user_id,document_type,title,content_markdown,content_plain,sections_json,source_excerpt,
|
||||
prompt_version,model,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
doc.RecordID, s.tenant(), formatInt64(userID), doc.DocumentType, doc.Title, doc.ContentMarkdown, doc.ContentPlain,
|
||||
doc.SectionsJSON, doc.SourceExcerpt, doc.PromptVersion, doc.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) AIDocuments(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
return s.aiRecords(ctx, userID, "smart_document", queryText, cursor, limit)
|
||||
}
|
||||
|
||||
func (s *Store) AIDocument(ctx context.Context, userID int64, recordID string) (*AIDocument, error) {
|
||||
var doc AIDocument
|
||||
err := s.db.GetContext(ctx, &doc, `SELECT d.record_id,d.user_id,d.document_type,d.title,d.content_markdown,
|
||||
COALESCE(d.content_plain,'') AS content_plain,COALESCE(CAST(d.sections_json AS CHAR), '[]') AS sections_json,
|
||||
d.source_excerpt,d.prompt_version,d.model,d.created_at,d.updated_at
|
||||
FROM wb_ai_document d
|
||||
JOIN wb_ai_record r ON r.tenant_id=d.tenant_id AND r.record_id=d.record_id
|
||||
WHERE d.tenant_id=? AND d.user_id=? AND d.record_id=? AND r.deleted_at IS NULL`,
|
||||
s.tenant(), formatInt64(userID), recordID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveAITableAnalysis(ctx context.Context, tenantCode string, userID int64, record AIRecord, analysis AITableAnalysis) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record
|
||||
(record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary,
|
||||
record.Status, analysis.RecordID, record.TaskID, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_table_analysis
|
||||
(record_id,tenant_id,user_id,file_id,file_name,file_type,file_size,topic,metrics_json,preview_json,alerts_json,
|
||||
interpretation,export_file_id,prompt_version,model,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
analysis.RecordID, s.tenant(), formatInt64(userID), analysis.FileID, analysis.FileName, analysis.FileType, analysis.FileSize,
|
||||
analysis.Topic, analysis.MetricsJSON, analysis.PreviewJSON, analysis.AlertsJSON, analysis.Interpretation, analysis.ExportFileID,
|
||||
analysis.PromptVersion, analysis.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) SaveCompletedFileUpload(ctx context.Context, file CompletedFileUpload) error {
|
||||
now := file.CreatedAt.UTC().Round(time.Millisecond)
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC().Round(time.Millisecond)
|
||||
}
|
||||
db := s.db
|
||||
if s.core != nil && s.core.db != nil {
|
||||
db = s.core.db
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO im_fileuploads
|
||||
(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
s.tenant(), file.ID, now, now, formatInt64(file.UserID), 1, file.MIMEType, file.Size, file.ETag, file.Location)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAITableAnalysisExportFileID(ctx context.Context, userID int64, recordID, fileID string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_table_analysis SET export_file_id=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND record_id=?`,
|
||||
fileID, now, s.tenant(), formatInt64(userID), recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AITableAnalyses(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
where := `r.tenant_id=? AND r.user_id=? AND r.record_type='smart_table' AND r.deleted_at IS NULL`
|
||||
args := []any{s.tenant(), formatInt64(userID)}
|
||||
if queryText != "" {
|
||||
where += ` AND (r.title LIKE ? OR r.summary LIKE ? OR a.file_name LIKE ?)`
|
||||
like := "%" + queryText + "%"
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
if cursor != "" {
|
||||
where += ` AND r.record_id<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
var items []AIRecord
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT r.record_id,r.tenant_code,r.user_id,r.record_type,r.title,r.summary,r.status,r.detail_id,r.task_id,
|
||||
COALESCE(a.file_name, '') AS file_name,COALESCE(a.file_type, '') AS file_type,COALESCE(a.file_size, 0) AS file_size,
|
||||
r.created_at,r.updated_at,r.deleted_at
|
||||
FROM wb_ai_record r
|
||||
LEFT JOIN wb_ai_table_analysis a ON a.tenant_id=r.tenant_id AND a.record_id=r.detail_id
|
||||
WHERE `+where+` ORDER BY r.created_at DESC,r.record_id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
next = items[limit-1].RecordID
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, next, nil
|
||||
}
|
||||
|
||||
func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
return s.aiRecords(ctx, userID, recordType, queryText, cursor, limit)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAIRecord(ctx context.Context, userID int64, recordID string) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_record SET status='deleted',deleted_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND record_id=? AND deleted_at IS NULL`,
|
||||
now, now, s.tenant(), formatInt64(userID), recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AITableAnalysis(ctx context.Context, userID int64, recordID string) (*AITableAnalysis, error) {
|
||||
var item AITableAnalysis
|
||||
err := s.db.GetContext(ctx, &item, `SELECT a.record_id,a.user_id,a.file_id,a.file_name,a.file_type,a.file_size,a.topic,
|
||||
COALESCE(CAST(a.metrics_json AS CHAR), '[]') AS metrics_json,
|
||||
COALESCE(CAST(a.preview_json AS CHAR), '{}') AS preview_json,
|
||||
COALESCE(CAST(a.alerts_json AS CHAR), '[]') AS alerts_json,
|
||||
a.interpretation,a.export_file_id,a.prompt_version,a.model,a.created_at,a.updated_at
|
||||
FROM wb_ai_table_analysis a
|
||||
JOIN wb_ai_record r ON r.tenant_id=a.tenant_id AND r.record_id=a.record_id
|
||||
WHERE a.tenant_id=? AND a.user_id=? AND a.record_id=? AND r.deleted_at IS NULL`,
|
||||
s.tenant(), formatInt64(userID), recordID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAIMeetingSummary(ctx context.Context, tenantCode string, userID int64, summary AIMeetingSummary) error {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_meeting_summary
|
||||
(summary_id,tenant_id,tenant_code,meeting_id,user_id,task_id,conclusions_json,decisions_json,action_items_json,
|
||||
source_file_ids_json,prompt_version,model,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
||||
ON DUPLICATE KEY UPDATE task_id=VALUES(task_id),conclusions_json=VALUES(conclusions_json),
|
||||
decisions_json=VALUES(decisions_json),action_items_json=VALUES(action_items_json),
|
||||
source_file_ids_json=VALUES(source_file_ids_json),prompt_version=VALUES(prompt_version),model=VALUES(model),
|
||||
version=version+1,updated_at=VALUES(updated_at)`,
|
||||
summary.SummaryID, s.tenant(), tenantCode, summary.MeetingID, formatInt64(userID), summary.TaskID,
|
||||
summary.ConclusionsJSON, summary.DecisionsJSON, summary.ActionItemsJSON, summary.SourceFileIDsJSON,
|
||||
summary.PromptVersion, summary.Model, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AIMeetingSummary(ctx context.Context, userID int64, meetingID string) (*AIMeetingSummary, error) {
|
||||
var item AIMeetingSummary
|
||||
err := s.db.GetContext(ctx, &item, `SELECT summary_id,meeting_id,user_id,task_id,
|
||||
COALESCE(CAST(conclusions_json AS CHAR), '[]') AS conclusions_json,
|
||||
COALESCE(CAST(decisions_json AS CHAR), '[]') AS decisions_json,
|
||||
COALESCE(CAST(action_items_json AS CHAR), '[]') AS action_items_json,
|
||||
COALESCE(CAST(source_file_ids_json AS CHAR), '[]') AS source_file_ids_json,
|
||||
prompt_version,model,edited_by,edited_at,version,created_at,updated_at
|
||||
FROM wb_ai_meeting_summary WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAIMeetingSummary(ctx context.Context, userID int64, meetingID, summaryID, conclusionsJSON, decisionsJSON, actionItemsJSON string) (*AIMeetingSummary, error) {
|
||||
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meeting.OrganizerID != userID && meeting.RecorderID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_meeting_summary SET conclusions_json=?,decisions_json=?,action_items_json=?,
|
||||
edited_by=?,edited_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND summary_id=?`,
|
||||
conclusionsJSON, decisionsJSON, actionItemsJSON, formatInt64(userID), now, now, s.tenant(), meetingID, summaryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.AIMeetingSummary(ctx, userID, meetingID)
|
||||
}
|
||||
|
||||
func (s *Store) aiRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) {
|
||||
where := `tenant_id=? AND user_id=? AND deleted_at IS NULL`
|
||||
args := []any{s.tenant(), formatInt64(userID)}
|
||||
if recordType != "" {
|
||||
where += ` AND record_type=?`
|
||||
args = append(args, recordType)
|
||||
}
|
||||
if queryText != "" {
|
||||
where += ` AND (title LIKE ? OR summary LIKE ?)`
|
||||
like := "%" + queryText + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if cursor != "" {
|
||||
where += ` AND record_id<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
var items []AIRecord
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT record_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,
|
||||
created_at,updated_at,deleted_at FROM wb_ai_record WHERE `+where+` ORDER BY created_at DESC,record_id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
next = items[limit-1].RecordID
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, next, nil
|
||||
}
|
||||
|
||||
func marshalNullable(value any) (any, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func formatInt64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// CoreStore provides read-only access to shared IM data.
|
||||
type CoreStore struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func coreStoreOwnsDB(primaryDSN, coreDSN string) bool {
|
||||
return strings.TrimSpace(primaryDSN) != strings.TrimSpace(coreDSN)
|
||||
}
|
||||
|
||||
func openCoreDSN(cfg config.MySQLConfig) (*CoreStore, error) {
|
||||
db, err := openMySQLDB(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CoreStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *CoreStore) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *CoreStore) fileUploadMetadata(ctx context.Context, tenantID int64, fileID string) fileUploadMetadata {
|
||||
var meta fileUploadMetadata
|
||||
decodedID, storageName := parseUploadFileID(fileID)
|
||||
locationCandidates := uploadLocationCandidates(fileID, storageName)
|
||||
if decodedID <= 0 && len(locationCandidates) == 0 {
|
||||
return meta
|
||||
}
|
||||
if decodedID > 0 {
|
||||
err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name FROM im_fileuploads WHERE tenant_id=? AND id=? LIMIT 1`, tenantID, decodedID)
|
||||
if err == nil {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
for _, candidate := range locationCandidates {
|
||||
err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name
|
||||
FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1`, tenantID, candidate, "%/"+candidate)
|
||||
if err == nil {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func (s *CoreStore) populateUserSummaries(ctx context.Context, tenantID int64, ids []int64, result map[int64]UserSummary) error {
|
||||
query, args, err := sqlx.In(`SELECT user_id,nickname AS display_name,IFNULL(avatar_file_id,'') AS avatar_file_id
|
||||
FROM im_user_profiles WHERE tenant_id=? AND user_id IN (?) ORDER BY user_id ASC`, tenantID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var profileRows []struct {
|
||||
UserID int64 `db:"user_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
AvatarFileID string `db:"avatar_file_id"`
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &profileRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
||||
return err
|
||||
}
|
||||
for _, row := range profileRows {
|
||||
summary := result[row.UserID]
|
||||
if summary.UserID == 0 {
|
||||
summary.UserID = row.UserID
|
||||
}
|
||||
if summary.DisplayName == "" {
|
||||
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
||||
}
|
||||
if summary.AvatarFileID == "" {
|
||||
summary.AvatarFileID = row.AvatarFileID
|
||||
}
|
||||
result[row.UserID] = summary
|
||||
}
|
||||
|
||||
query, args, err = sqlx.In(`SELECT id AS user_id,
|
||||
COALESCE(
|
||||
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.display_name')),''),
|
||||
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.fn')),''),
|
||||
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.nickname')),''),
|
||||
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.name')),''),
|
||||
''
|
||||
) AS display_name
|
||||
FROM im_users WHERE tenant_id=? AND id IN (?)`, tenantID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var userRows []struct {
|
||||
UserID int64 `db:"user_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &userRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
||||
return err
|
||||
}
|
||||
for _, row := range userRows {
|
||||
summary := result[row.UserID]
|
||||
if summary.UserID == 0 {
|
||||
summary.UserID = row.UserID
|
||||
}
|
||||
if summary.DisplayName == "" {
|
||||
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
||||
}
|
||||
result[row.UserID] = summary
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCoreStoreOwnsDBOnlyForDifferentDSN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
primaryDSN string
|
||||
coreDSN string
|
||||
wantOwnDB bool
|
||||
}{
|
||||
{name: "same", primaryDSN: "root@tcp(db:3306)/meeting_im", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: false},
|
||||
{name: "same with whitespace", primaryDSN: " root@tcp(db:3306)/meeting_im ", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: false},
|
||||
{name: "different", primaryDSN: "root@tcp(db:3306)/workbench", coreDSN: "root@tcp(db:3306)/meeting_im", wantOwnDB: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := coreStoreOwnsDB(tt.primaryDSN, tt.coreDSN); got != tt.wantOwnDB {
|
||||
t.Fatalf("coreStoreOwnsDB() = %t, want %t", got, tt.wantOwnDB)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,32 @@ package store
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrPolicy = errors.New("policy violation")
|
||||
ErrAccountExists = errors.New("account already exists")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrAccountRestricted = errors.New("account restricted")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
ErrConcurrentUpdate = errors.New("concurrent update")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrReferralInvalid = errors.New("referral code invalid")
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrTaskHallNotFound = errors.New("task hall not found")
|
||||
ErrTaskDisabled = errors.New("task feature disabled")
|
||||
ErrOutsideBusinessHours = errors.New("outside business hours")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrDailyLimitReached = errors.New("daily limit reached")
|
||||
ErrLevelNotEligible = errors.New("member level not eligible")
|
||||
ErrPendingOrderExists = errors.New("pending order exists")
|
||||
ErrPaymentAccountExists = errors.New("payment account already exists")
|
||||
ErrPaymentAccountInvalid = errors.New("payment account invalid")
|
||||
ErrWithdrawalDisabled = errors.New("withdrawal disabled")
|
||||
ErrWithdrawalChannel = errors.New("withdrawal channel unavailable")
|
||||
ErrWithdrawalAmount = errors.New("withdrawal amount invalid")
|
||||
ErrWithdrawalLimit = errors.New("withdrawal daily limit reached")
|
||||
ErrWithdrawalNotFound = errors.New("withdrawal order not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key reused with different request")
|
||||
ErrNoTaskAvailable = errors.New("no task available")
|
||||
ErrOrderNotFound = errors.New("task order not found")
|
||||
ErrOrderState = errors.New("task order state invalid")
|
||||
ErrOrderExpired = errors.New("task order expired")
|
||||
ErrEvidenceInvalid = errors.New("task evidence invalid")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ManualProduct struct {
|
||||
ID int64 `db:"id"`
|
||||
ProductNo string `db:"product_no"`
|
||||
CategoryID int64 `db:"category_id"`
|
||||
CategoryCode string `db:"category_code"`
|
||||
CategoryName string `db:"category_name"`
|
||||
PaymentType string `db:"payment_type"`
|
||||
Name string `db:"name"`
|
||||
ShopName string `db:"shop_name"`
|
||||
PriceAmount int64 `db:"price_amount"`
|
||||
Currency string `db:"currency"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
DetailHTML string `db:"detail_html"`
|
||||
IsRecommended bool `db:"is_recommended"`
|
||||
ClaimedCount int64 `db:"claimed_count"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ManualProductPage struct {
|
||||
Items []ManualProduct
|
||||
Total int64
|
||||
}
|
||||
|
||||
func (s *Store) ManualProducts(ctx context.Context, tenantID, categoryID int64, offset, limit int) (*ManualProductPage, error) {
|
||||
where := ` WHERE p.tenant_id=? AND p.product_type=2 AND p.publish_status=3
|
||||
AND p.deleted_at IS NULL AND c.status=1 AND c.deleted_at IS NULL
|
||||
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
|
||||
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3))`
|
||||
args := []any{tenantID}
|
||||
if categoryID > 0 {
|
||||
where += ` AND p.category_id=?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_product p
|
||||
JOIN hg_product_category c ON c.tenant_id=p.tenant_id AND c.id=p.category_id`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
items := make([]ManualProduct, 0)
|
||||
err := s.db.SelectContext(ctx, &items, manualProductSelectSQL()+where+
|
||||
` ORDER BY p.is_recommended ASC,p.sort ASC,p.id ASC LIMIT ? OFFSET ?`, args...)
|
||||
return &ManualProductPage{Items: items, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) ManualProduct(ctx context.Context, tenantID, productID int64) (*ManualProduct, error) {
|
||||
var row ManualProduct
|
||||
err := s.db.GetContext(ctx, &row, manualProductSelectSQL()+` WHERE p.tenant_id=? AND p.id=?
|
||||
AND p.product_type=2 AND p.publish_status=3 AND p.deleted_at IS NULL
|
||||
AND c.status=1 AND c.deleted_at IS NULL
|
||||
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
|
||||
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3)) LIMIT 1`, tenantID, productID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func manualProductSelectSQL() string {
|
||||
return `SELECT p.id,p.product_no,p.category_id,c.category_code,c.name AS category_name,
|
||||
p.payment_type,p.name,p.shop_name,p.price_amount,p.currency,p.cover_url,
|
||||
COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended,
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id
|
||||
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
|
||||
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_product p JOIN hg_product_category c
|
||||
ON c.tenant_id=p.tenant_id AND c.id=p.category_id`
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeMeetingNoteFileMetadataUsesUploadMetadata(t *testing.T) {
|
||||
gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{
|
||||
FileID: "lxzvtUzicEo",
|
||||
}, fileUploadMetadata{
|
||||
MIMEType: "application/pdf",
|
||||
FileSize: 12345,
|
||||
FileETag: "etag-1",
|
||||
Location: "uploads/wontuse",
|
||||
OriginalName: "会议纪要.pdf",
|
||||
}, "lxzvtUzicEo")
|
||||
if gotName != "会议纪要.pdf" {
|
||||
t.Fatalf("name=%q, want original upload name", gotName)
|
||||
}
|
||||
if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" {
|
||||
t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingNoteFileMetadataFallsBackToLocation(t *testing.T) {
|
||||
gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{
|
||||
FileID: "lxzvtUzicEo",
|
||||
}, fileUploadMetadata{
|
||||
MIMEType: "application/pdf",
|
||||
FileSize: 12345,
|
||||
FileETag: "etag-1",
|
||||
Location: "uploads/lxzvtUzicEo.pdf",
|
||||
}, "lxzvtUzicEo")
|
||||
if gotName != "lxzvtUzicEo.pdf" {
|
||||
t.Fatalf("name=%q, want location file name", gotName)
|
||||
}
|
||||
if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" {
|
||||
t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingNoteFileMetadataKeepsRequestMetadata(t *testing.T) {
|
||||
size := int64(88)
|
||||
gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{
|
||||
FileID: "file_1",
|
||||
FileName: "../会议纪要.docx",
|
||||
MIMEType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileSize: &size,
|
||||
FileETag: "request-etag",
|
||||
}, fileUploadMetadata{
|
||||
MIMEType: "application/pdf",
|
||||
FileSize: 12345,
|
||||
FileETag: "upload-etag",
|
||||
Location: "uploads/file_1.pdf",
|
||||
}, "file_1")
|
||||
if gotName != "会议纪要.docx" {
|
||||
t.Fatalf("name=%q, want original file name", gotName)
|
||||
}
|
||||
if gotMime != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || gotSize != 88 || gotETag != "request-etag" {
|
||||
t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadFileIDAcceptsExtension(t *testing.T) {
|
||||
decodedID, storageName := parseUploadFileID("lxzvtUzicEo.pdf")
|
||||
if decodedID <= 0 {
|
||||
t.Fatalf("decodedID=%d, want positive", decodedID)
|
||||
}
|
||||
if storageName == "" {
|
||||
t.Fatal("storageName is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadFileIDAcceptsOSSURL(t *testing.T) {
|
||||
decodedID, storageName := parseUploadFileID("https://oss.example.com/files/lxzvtUzicEo.pdf?Expires=1")
|
||||
if decodedID <= 0 {
|
||||
t.Fatalf("decodedID=%d, want positive", decodedID)
|
||||
}
|
||||
if storageName == "" {
|
||||
t.Fatal("storageName is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadFileIDAcceptsObjectKey(t *testing.T) {
|
||||
decodedID, storageName := parseUploadFileID("tenants/1/2026/09/s4oo7nkm4jyeu")
|
||||
if decodedID != 0 {
|
||||
t.Fatalf("decodedID=%d, want 0 for object key", decodedID)
|
||||
}
|
||||
if storageName != "s4oo7nkm4jyeu" {
|
||||
t.Fatalf("storageName=%q, want s4oo7nkm4jyeu", storageName)
|
||||
}
|
||||
candidates := uploadLocationCandidates("tenants/1/2026/09/s4oo7nkm4jyeu", storageName)
|
||||
if len(candidates) < 2 || candidates[0] != "s4oo7nkm4jyeu" || candidates[1] != "tenants/1/2026/09/s4oo7nkm4jyeu" {
|
||||
t.Fatalf("candidates=%#v", candidates)
|
||||
}
|
||||
}
|
||||
+3
-1329
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const publishStatusPublished = 3
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `db:"id"`
|
||||
ImageURL string `db:"image_url"`
|
||||
AltText string `db:"alt_text"`
|
||||
TargetType int `db:"target_type"`
|
||||
TargetValue string `db:"target_value"`
|
||||
Sort int `db:"sort"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ContentKey string `db:"content_key"`
|
||||
ContentType string `db:"content_type"`
|
||||
Title string `db:"title"`
|
||||
Summary string `db:"summary"`
|
||||
BodyHTML string `db:"body_html"`
|
||||
BodyText string `db:"body_text"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
Version int `db:"version"`
|
||||
PublishedAt string `db:"published_at"`
|
||||
AssetIDs sql.NullString `db:"asset_ids"`
|
||||
}
|
||||
|
||||
type MemberLevel struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"level_code"`
|
||||
Name string `db:"name"`
|
||||
IconURL string `db:"icon_url"`
|
||||
UpgradeAmount int64 `db:"upgrade_amount"`
|
||||
GiftAmount int64 `db:"gift_amount"`
|
||||
DailyOrderLimit int `db:"daily_order_limit"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
DailyWithdrawLimit int `db:"daily_withdraw_limit"`
|
||||
RuleJSON json.RawMessage `db:"rule_json"`
|
||||
Version int `db:"version"`
|
||||
Sort int `db:"sort"`
|
||||
}
|
||||
|
||||
type MemberNotice struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Content string `db:"content"`
|
||||
SourceType int `db:"source_type"`
|
||||
StartAt string `db:"start_at"`
|
||||
EndAt string `db:"end_at"`
|
||||
}
|
||||
|
||||
type TaskSummary struct {
|
||||
TodayCount int `db:"today_count" json:"today_count"`
|
||||
TodayCommissionAmount int64 `db:"today_commission_amount" json:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"`
|
||||
}
|
||||
|
||||
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var raw []byte
|
||||
err := s.db.GetContext(ctx, &raw, `SELECT config_json FROM hg_site_config_version
|
||||
WHERE tenant_id=? AND publish_status=? AND (effective_at IS NULL OR effective_at<=UTC_TIMESTAMP(3))
|
||||
ORDER BY COALESCE(effective_at,published_at,created_at) DESC,id DESC LIMIT 1`, tenantID, publishStatusPublished)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
return json.RawMessage(raw), err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveBanners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]Banner, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,image_url,alt_text,target_type,COALESCE(target_value,'') AS target_value,sort
|
||||
FROM hg_banner WHERE tenant_id=? AND position=? AND publish_status=?
|
||||
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
|
||||
AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID, position, publishStatusPublished)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) PublishedContent(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row Content
|
||||
err := s.db.GetContext(ctx, &row, `SELECT c.content_key,c.content_type,v.title,COALESCE(v.summary,'') AS summary,
|
||||
v.body_html,COALESCE(v.body_text,'') AS body_text,COALESCE(v.cover_url,'') AS cover_url,
|
||||
v.version_no AS version,COALESCE(DATE_FORMAT(c.published_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS published_at,v.asset_ids
|
||||
FROM hg_content c JOIN hg_content_version v
|
||||
ON v.tenant_id=c.tenant_id AND v.id=c.published_version_id
|
||||
WHERE c.tenant_id=? AND c.content_key=? AND c.publish_status=? AND v.publish_status=?
|
||||
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
|
||||
AND c.deleted_at IS NULL LIMIT 1`, tenantID, key, publishStatusPublished, publishStatusPublished)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveMemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberLevel, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT l.id,l.level_code,l.name,l.icon_url,l.sort,
|
||||
v.upgrade_amount,v.gift_amount,v.daily_order_limit,v.commission_rate,v.daily_withdraw_limit,
|
||||
COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json,v.version_no AS version
|
||||
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
|
||||
WHERE l.tenant_id=? AND l.status=1 AND v.publish_status=?
|
||||
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
|
||||
AND l.deleted_at IS NULL ORDER BY l.sort ASC,l.id ASC`, tenantID, publishStatusPublished)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int64, limit int) ([]MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberNotice, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `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
|
||||
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 ?`, tenantID, memberID, limit)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row TaskSummary
|
||||
err := s.db.GetContext(ctx, &row, `SELECT
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=? AND o.member_id=?
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
|
||||
(SELECT COALESCE(SUM(t.commission_amount),0) FROM hg_task_submission t WHERE t.tenant_id=? AND t.member_id=?
|
||||
AND t.submission_status=4 AND t.settled_at>=UTC_DATE() AND t.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(y.commission_amount),0) FROM hg_task_submission y WHERE y.tenant_id=? AND y.member_id=?
|
||||
AND y.submission_status=4 AND y.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND y.settled_at<UTC_DATE()) AS yesterday_commission_amount`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
return &row, err
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type MemberOverview struct {
|
||||
TenantID int64 `db:"tenant_id"`
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
CreditScore int `db:"credit_score"`
|
||||
AvatarAttachmentID int64 `db:"avatar_attachment_id"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
LevelID int64 `db:"level_id"`
|
||||
LevelCode string `db:"level_code"`
|
||||
LevelName string `db:"level_name"`
|
||||
LevelIconURL string `db:"level_icon_url"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TotalIncome int64 `db:"total_income"`
|
||||
TotalExpense int64 `db:"total_expense"`
|
||||
Currency string `db:"currency"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
WalletVersion int `db:"wallet_version"`
|
||||
}
|
||||
|
||||
type AccountSecurity struct {
|
||||
PasswordHash string `db:"password_hash"`
|
||||
TradePasswordHash string `db:"trade_password_hash"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
}
|
||||
|
||||
type Avatar struct {
|
||||
AttachmentID int64 `db:"attachment_id"`
|
||||
URL string `db:"url"`
|
||||
}
|
||||
|
||||
func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*MemberOverview, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberOverview
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile_mask,
|
||||
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score,
|
||||
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
|
||||
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
|
||||
COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
|
||||
COALESCE(lv.commission_rate,0) AS commission_rate,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
|
||||
COALESCE(w.total_income,0) AS total_income,COALESCE(w.total_expense,0) AS total_expense,
|
||||
COALESCE(w.currency,'CNY') AS currency,COALESCE(w.account_status,1) AS wallet_status,
|
||||
COALESCE(w.version,0) AS wallet_version
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
|
||||
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*Avatar, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var activeMemberID int64
|
||||
err = tx.GetContext(ctx, &activeMemberID, `SELECT id FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
|
||||
(tenant_id,member_id,avatar_attachment_id,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0) ON DUPLICATE KEY UPDATE avatar_attachment_id=VALUES(avatar_attachment_id),updated_at=CURRENT_TIMESTAMP(3)`,
|
||||
tenantID, memberID, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &avatar, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountSecurity(ctx context.Context, tenantID, memberID int64) (*AccountSecurity, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row AccountSecurity
|
||||
err := s.db.GetContext(ctx, &row, `SELECT password_hash,trade_password_hash,member_status
|
||||
FROM hg_app_member WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLoginPassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTradePassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET trade_password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusMatching = 1
|
||||
orderStatusPending = 2
|
||||
orderStatusProcessing = 3
|
||||
orderStatusCompleted = 4
|
||||
orderStatusExpired = 5
|
||||
orderStatusFrozen = 6
|
||||
orderStatusCancelled = 7
|
||||
orderStatusFailed = 8
|
||||
)
|
||||
|
||||
type TaskHall struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"hall_code"`
|
||||
Name string `db:"name"`
|
||||
IconURL string `db:"icon_url"`
|
||||
Summary string `db:"summary"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
RateUnit string `db:"rate_unit"`
|
||||
ClientRoute string `db:"client_route"`
|
||||
}
|
||||
|
||||
type TaskEligibility struct {
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
HallEligible bool `db:"hall_eligible"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
PendingOrderID int64 `db:"pending_order_id"`
|
||||
}
|
||||
|
||||
type ClaimTaskInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
HallID int64
|
||||
MinimumAmount int64
|
||||
DeadlineAt time.Time
|
||||
OrderNo string
|
||||
}
|
||||
|
||||
type TaskOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
ProductID int64 `db:"product_id"`
|
||||
ProductName string `db:"product_name"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
Quantity int `db:"quantity"`
|
||||
UnitPriceAmount int64 `db:"unit_price_amount"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
Currency string `db:"currency"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||
Message string `db:"message"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
FreezeStatus int `db:"freeze_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
MatchedAt string `db:"matched_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
TaskTitle string `db:"task_title"`
|
||||
TaskDescription string `db:"task_description"`
|
||||
TaskDetailHTML string `db:"task_detail_html"`
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
SubmissionStatus int `db:"submission_status"`
|
||||
EvidenceText string `db:"evidence_text"`
|
||||
EvidenceIDs json.RawMessage `db:"evidence_attachment_ids"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
AllowResubmit int `db:"allow_resubmit"`
|
||||
SubmittedAt string `db:"submitted_at"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
SettledAt string `db:"settled_at"`
|
||||
}
|
||||
|
||||
type TaskOrderPage struct {
|
||||
Items []TaskOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
type TaskStats struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TodayCommissionAmount int64 `db:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount"`
|
||||
TodayClaimCount int `db:"today_claim_count"`
|
||||
TodayCompletedCount int `db:"today_completed_count"`
|
||||
}
|
||||
|
||||
type TaskSubmissionInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
}
|
||||
|
||||
type TaskSubmissionResult struct {
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
OrderStatus int
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]TaskHall, error) {
|
||||
rows := make([]TaskHall, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT h.id,h.hall_code,h.name,h.icon_url,h.summary,
|
||||
h.commission_rate,h.rate_unit,h.client_route FROM hg_task_hall h
|
||||
JOIN hg_app_member m ON m.tenant_id=h.tenant_id AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL
|
||||
JOIN hg_task_hall_level hl ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
||||
WHERE h.tenant_id=? AND h.publish_status=3 AND h.deleted_at IS NULL ORDER BY h.sort,h.id`, memberID, tenantID)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) {
|
||||
var row TaskEligibility
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,
|
||||
EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl
|
||||
ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
||||
WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.account_status,0) AS wallet_status,
|
||||
COALESCE(lv.daily_order_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=m.tenant_id AND o.member_id=m.id
|
||||
AND o.order_type=2 AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
|
||||
COALESCE((SELECT o2.id FROM hg_trade_order o2 WHERE o2.tenant_id=m.tenant_id AND o2.member_id=m.id
|
||||
AND o2.order_type=2 AND o2.order_status IN (1,2,3,6) ORDER BY o2.id DESC LIMIT 1),0) AS pending_order_id
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.deleted_at IS NULL LIMIT 1`, hallID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var member struct {
|
||||
LevelID int64 `db:"level_id"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id 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
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hallCount int
|
||||
err = tx.GetContext(ctx, &hallCount, `SELECT COUNT(*) FROM hg_task_hall h
|
||||
JOIN hg_task_hall_level hl ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=?
|
||||
WHERE h.tenant_id=? AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL`, member.LevelID, input.TenantID, input.HallID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hallCount == 0 {
|
||||
return nil, ErrLevelNotEligible
|
||||
}
|
||||
var wallet struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
Status int `db:"account_status"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &wallet, `SELECT available_amount,account_status 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, ErrInsufficientBalance
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.Status != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.AvailableAmount < input.MinimumAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
var pending int64
|
||||
err = tx.GetContext(ctx, &pending, `SELECT id FROM hg_trade_order
|
||||
WHERE tenant_id=? AND member_id=? AND order_type=2 AND order_status IN (1,2,3,6)
|
||||
ORDER BY id DESC LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if pending > 0 {
|
||||
return nil, ErrPendingOrderExists
|
||||
}
|
||||
var dailyLimit int
|
||||
err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l
|
||||
LEFT 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`, input.TenantID, member.LevelID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrLevelNotEligible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dailyLimit > 0 {
|
||||
var todayCount int
|
||||
if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=?
|
||||
AND order_type=2 AND created_at>=UTC_DATE() AND created_at<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if todayCount >= dailyLimit {
|
||||
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
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,created_by,updated_by)
|
||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,1,1,0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||
member.LevelID, rules, "等待系统匹配")
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
orderID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||
where := ` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2`
|
||||
args := []any{tenantID, memberID}
|
||||
if status > 0 {
|
||||
where += ` AND o.order_status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_trade_order o`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
rows := make([]TaskOrder, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, orderSelectSQL()+where+` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
return &TaskOrderPage{Items: rows, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrder(ctx context.Context, tenantID, memberID, orderID int64) (*TaskOrder, error) {
|
||||
var row TaskOrder
|
||||
err := s.db.GetContext(ctx, &row, orderSelectSQL()+` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2 AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func orderSelectSQL() string {
|
||||
return `SELECT o.id,o.order_no,o.task_id,o.product_id,COALESCE(NULLIF(o.product_name,''),t.title,'') AS product_name,
|
||||
COALESCE(NULLIF(p.cover_url,''),t.cover_url,'') AS cover_url,o.quantity,o.unit_price_amount,
|
||||
o.total_amount,o.commission_amount,o.currency,o.member_level_id,
|
||||
COALESCE(o.rule_snapshot,JSON_OBJECT()) AS rule_snapshot,o.message,o.order_status,o.freeze_status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
|
||||
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
|
||||
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
|
||||
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
|
||||
COALESCE(s.submission_status,0) AS submission_status,COALESCE(s.evidence_text,'') AS evidence_text,
|
||||
COALESCE(s.evidence_attachment_ids,JSON_ARRAY()) AS evidence_attachment_ids,
|
||||
COALESCE(s.review_reason_code,'') AS review_reason_code,COALESCE(s.review_reason,'') AS review_reason,
|
||||
COALESCE(s.allow_resubmit,2) AS allow_resubmit,
|
||||
COALESCE(DATE_FORMAT(s.created_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS submitted_at,
|
||||
COALESCE(DATE_FORMAT(s.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(s.settled_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS settled_at
|
||||
FROM hg_trade_order o LEFT JOIN hg_task t ON t.tenant_id=o.tenant_id AND t.id=o.task_id
|
||||
LEFT JOIN hg_product p ON p.tenant_id=o.tenant_id AND p.id=o.product_id
|
||||
LEFT JOIN hg_task_submission s ON s.tenant_id=o.tenant_id AND s.trade_order_id=o.id`
|
||||
}
|
||||
|
||||
func (s *Store) TaskStats(ctx context.Context, tenantID, memberID int64) (*TaskStats, error) {
|
||||
var row TaskStats
|
||||
err := s.db.GetContext(ctx, &row, `SELECT
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
|
||||
(SELECT COALESCE(SUM(ts.commission_amount),0) FROM hg_task_submission ts WHERE ts.tenant_id=? AND ts.member_id=?
|
||||
AND ts.submission_status=4 AND ts.settled_at>=UTC_DATE() AND ts.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(ys.commission_amount),0) FROM hg_task_submission ys WHERE ys.tenant_id=? AND ys.member_id=?
|
||||
AND ys.submission_status=4 AND ys.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND ys.settled_at<UTC_DATE()) AS yesterday_commission_amount,
|
||||
(SELECT COUNT(*) FROM hg_trade_order tc WHERE tc.tenant_id=? AND tc.member_id=?
|
||||
AND tc.order_type=2 AND tc.created_at>=UTC_DATE() AND tc.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_claim_count,
|
||||
(SELECT COUNT(*) FROM hg_trade_order cc WHERE cc.tenant_id=? AND cc.member_id=? AND cc.order_type=2 AND cc.order_status=4
|
||||
AND cc.completed_at>=UTC_DATE() AND cc.completed_at<UTC_DATE()+INTERVAL 1 DAY) AS today_completed_count
|
||||
FROM hg_app_member m LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*TaskSubmissionResult, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var order struct {
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
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,
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if order.DeadlineAt != "" {
|
||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||
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
|
||||
}
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
return nil, commitErr
|
||||
}
|
||||
return nil, ErrOrderExpired
|
||||
}
|
||||
}
|
||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
(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)
|
||||
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 err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
||||
}
|
||||
|
||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
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 (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := tx.GetContext(ctx, &count, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(ids) {
|
||||
return ErrEvidenceInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, businessNumbers []string) ([]map[string]any, error) {
|
||||
if len(businessNumbers) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
|
||||
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger
|
||||
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
item := map[string]any{}
|
||||
if err := rows.MapScan(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range item {
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
item[key] = string(bytes)
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TenantInitializationResult reports how many missing defaults were created.
|
||||
// Existing tenant customizations are deliberately left untouched.
|
||||
type TenantInitializationResult struct {
|
||||
WorkbenchApps int64
|
||||
AttendanceRules int64
|
||||
AttendanceShifts int64
|
||||
MailboxConfigs int64
|
||||
FeedbackConfigs int64
|
||||
}
|
||||
|
||||
// InitializeTenantDefaults creates the minimum workbench data required by a tenant.
|
||||
// Tenant-scoped unique indexes make this operation safe to retry.
|
||||
func (s *Store) InitializeTenantDefaults(ctx context.Context) (*TenantInitializationResult, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
tenantID := s.tenant()
|
||||
result := &TenantInitializationResult{}
|
||||
|
||||
apps := []struct {
|
||||
code, name, route string
|
||||
sort int
|
||||
}{
|
||||
{code: "attendance", name: "打卡", route: "/attendance", sort: 10},
|
||||
{code: "monthly_reports", name: "月报", route: "/reports/monthly", sort: 20},
|
||||
{code: "daily_reports", name: "日报", route: "/reports/daily", sort: 30},
|
||||
{code: "organization", name: "部门框架", route: "/organization", sort: 40},
|
||||
{code: "executive_mailbox", name: "举报信箱", route: "/executive-mailbox", sort: 50},
|
||||
{code: "meetings", name: "会议通知", route: "/meetings", sort: 60},
|
||||
{code: "meeting_notes", name: "会议笔记", route: "/meeting-notes", sort: 70},
|
||||
}
|
||||
for _, app := range apps {
|
||||
res, execErr := tx.ExecContext(ctx, `INSERT INTO im_workbench_apps
|
||||
(tenant_id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_workbench_apps WHERE tenant_id=? AND code=?
|
||||
)`,
|
||||
tenantID, app.code, app.name, "", app.route, app.sort, "", 1, "enabled", now, now,
|
||||
tenantID, app.code)
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
result.WorkbenchApps += rows
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO im_attendance_rules
|
||||
(tenant_id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,effective_from,effective_to,state,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,CURDATE(),NULL,'active',?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_attendance_rules WHERE tenant_id=? AND rule_id=?
|
||||
)`,
|
||||
tenantID, "att_rule_default", "默认考勤规则", "1,2,3,4,5", 0, 0, 1, 1, now, now,
|
||||
tenantID, "att_rule_default")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.AttendanceRules, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_shifts
|
||||
(tenant_id,shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,state,sort_order,created_at,updated_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_attendance_shifts WHERE tenant_id=? AND shift_id=?
|
||||
)`,
|
||||
tenantID, "shift_default", "att_rule_default", "默认班次", "09:00:00", "18:00:00", "08:00:00", "19:00:00", "active", 10, now, now,
|
||||
tenantID, "shift_default")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.AttendanceShifts, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_mailbox_config
|
||||
(tenant_id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||
SELECT ?,1,1,?,5000,?,'总经理信箱','请如实填写诉求。',?,?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_mailbox_config WHERE tenant_id=?
|
||||
)`,
|
||||
tenantID, `["subject","content"]`, `{"max_files":5,"purpose":"executive_mailbox"}`, now, now, now,
|
||||
tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.MailboxConfigs, _ = res.RowsAffected()
|
||||
|
||||
res, err = tx.ExecContext(ctx, `INSERT INTO im_feedback_config
|
||||
(tenant_id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||
SELECT ?,?,2000,?,1,'',?,?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_feedback_config WHERE tenant_id=?
|
||||
)`,
|
||||
tenantID, `["bug","suggestion","complaint","other"]`, `{"max_files":5,"purpose":"feedback"}`, now, now,
|
||||
tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.FeedbackConfigs, _ = res.RowsAffected()
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type WorkbenchModule struct {
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
IconFileID string `db:"icon_file_id"`
|
||||
Route string `db:"route"`
|
||||
SortOrder int `db:"sort_order"`
|
||||
UnreadCount int `db:"unread_count"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Permission string `db:"permission"`
|
||||
}
|
||||
|
||||
type OrganizationDepartment struct {
|
||||
DepartmentID string `db:"department_id"`
|
||||
Name string `db:"name"`
|
||||
ParentID string `db:"parent_id"`
|
||||
ChildCount int `db:"child_count"`
|
||||
MemberCount int `db:"member_count"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type OrganizationMember struct {
|
||||
UserID int64 `db:"user_id"`
|
||||
DepartmentID string `db:"department_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
Position string `db:"position"`
|
||||
Mobile string `db:"mobile"`
|
||||
Email string `db:"email"`
|
||||
AvatarFileID string `db:"avatar_file_id"`
|
||||
Status string `db:"status"`
|
||||
Contactable bool `db:"contactable"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type UserSummary struct {
|
||||
UserID int64
|
||||
DisplayName string
|
||||
AvatarFileID string
|
||||
}
|
||||
|
||||
type AttendanceRule struct {
|
||||
RuleID string `db:"rule_id"`
|
||||
Name string `db:"name"`
|
||||
Workday string `db:"workday"`
|
||||
LocationRequired bool `db:"location_required"`
|
||||
WifiRequired bool `db:"wifi_required"`
|
||||
AllowOutsideCheckin bool `db:"allow_outside_checkin"`
|
||||
AppealAllowed bool `db:"appeal_allowed"`
|
||||
AppealDeadline *time.Time `db:"appeal_deadline"`
|
||||
}
|
||||
|
||||
type AttendanceShift struct {
|
||||
ShiftID string `db:"shift_id"`
|
||||
RuleID string `db:"rule_id"`
|
||||
Name string `db:"name"`
|
||||
WorkStart string `db:"work_start"`
|
||||
WorkEnd string `db:"work_end"`
|
||||
CheckinStart string `db:"checkin_start"`
|
||||
CheckoutEnd string `db:"checkout_end"`
|
||||
}
|
||||
|
||||
type AttendanceCheckin struct {
|
||||
CheckinID string `db:"checkin_id"`
|
||||
Action string `db:"action"`
|
||||
ClientTime time.Time `db:"client_time"`
|
||||
ServerTime time.Time `db:"server_time"`
|
||||
Result string `db:"result"`
|
||||
Location string `db:"location"`
|
||||
Wifi string `db:"wifi"`
|
||||
Device string `db:"device"`
|
||||
}
|
||||
|
||||
type AttendanceRecord struct {
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Date time.Time `db:"record_date"`
|
||||
RuleID string `db:"rule_id"`
|
||||
ShiftID string `db:"shift_id"`
|
||||
Status string `db:"status"`
|
||||
Exceptions string `db:"exceptions"`
|
||||
AppealStatus string `db:"appeal_status"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Checkins []AttendanceCheckin
|
||||
}
|
||||
|
||||
type AttendanceConfig struct {
|
||||
Date time.Time
|
||||
Timezone string
|
||||
Rule AttendanceRule
|
||||
Shifts []AttendanceShift
|
||||
TodayRecord *AttendanceRecord
|
||||
}
|
||||
|
||||
type AttendanceStats struct {
|
||||
ExpectedCount int
|
||||
CheckedCount int
|
||||
ExceptionCount int
|
||||
NotCheckedCount int
|
||||
}
|
||||
|
||||
type AttendanceAppeal struct {
|
||||
AppealID string `db:"appeal_id"`
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
AppealType string `db:"appeal_type"`
|
||||
Reason string `db:"reason"`
|
||||
TargetAction string `db:"target_action"`
|
||||
TargetTime *time.Time `db:"target_time"`
|
||||
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||
Status string `db:"status"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type Meeting struct {
|
||||
MeetingID string `db:"meeting_id"`
|
||||
OrganizerID int64 `db:"organizer_id"`
|
||||
RecorderID int64 `db:"recorder_user_id"`
|
||||
Title string `db:"title"`
|
||||
StartAt time.Time `db:"start_at"`
|
||||
EndAt time.Time `db:"end_at"`
|
||||
Location string `db:"location"`
|
||||
State string `db:"state"`
|
||||
ReminderMinutes string `db:"reminder_minutes"`
|
||||
IdempotencyKey string `db:"idempotency_key"`
|
||||
Version int `db:"version"`
|
||||
CanceledAt *time.Time `db:"canceled_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Organizer UserSummary
|
||||
Recorder UserSummary
|
||||
Participants []MeetingParticipant
|
||||
}
|
||||
|
||||
type MeetingParticipant struct {
|
||||
MeetingID string `db:"meeting_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Response string `db:"response"`
|
||||
ResponseAt *time.Time `db:"response_at"`
|
||||
User UserSummary
|
||||
}
|
||||
|
||||
type MeetingNoteFile struct {
|
||||
NoteFileID string `db:"note_file_id"`
|
||||
MeetingID string `db:"meeting_id"`
|
||||
FileID string `db:"file_id"`
|
||||
UploaderID int64 `db:"uploader_id"`
|
||||
Uploader UserSummary
|
||||
FileName string `db:"file_name"`
|
||||
MIMEType string `db:"mime_type"`
|
||||
FileSize int64 `db:"file_size"`
|
||||
FileETag string `db:"file_etag"`
|
||||
Status string `db:"status"`
|
||||
PreviewStatus string `db:"preview_status"`
|
||||
PreviewMode string `db:"preview_mode"`
|
||||
PreviewFileID string `db:"preview_file_id"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type MeetingNoteFileInput struct {
|
||||
FileID string
|
||||
FileName string
|
||||
MIMEType string
|
||||
FileSize *int64
|
||||
FileETag string
|
||||
}
|
||||
|
||||
type DailyReport struct {
|
||||
ReportID string `db:"report_id"`
|
||||
AuthorID int64 `db:"author_id"`
|
||||
ReportDate time.Time `db:"report_date"`
|
||||
Content string `db:"content"`
|
||||
Attachments string `db:"attachments"`
|
||||
Status string `db:"status"`
|
||||
ReadState string `db:"read_state"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt *time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type MonthlyReport struct {
|
||||
ReportID string `db:"report_id"`
|
||||
AuthorID int64 `db:"author_id"`
|
||||
Month string `db:"report_month"`
|
||||
WorkSummary string `db:"work_summary"`
|
||||
NextPlan string `db:"next_plan"`
|
||||
Attachments string `db:"attachments"`
|
||||
Status string `db:"status"`
|
||||
ReadState string `db:"read_state"`
|
||||
Version int `db:"version"`
|
||||
SubmittedAt *time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ReportStats struct {
|
||||
ExpectedCount int
|
||||
SubmittedCount int
|
||||
MissingCount int
|
||||
DepartmentID string
|
||||
}
|
||||
|
||||
type MailboxConfig struct {
|
||||
Enabled bool `db:"enabled"`
|
||||
AllowAnonymous bool `db:"allow_anonymous"`
|
||||
RequiredFields string `db:"required_fields"`
|
||||
MaxLength int `db:"max_length"`
|
||||
AttachmentPolicy string `db:"attachment_policy"`
|
||||
NoticeTitle string `db:"notice_title"`
|
||||
NoticeContent string `db:"notice_content"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
|
||||
type MailboxSubmission struct {
|
||||
SubmissionID string `db:"submission_id"`
|
||||
SenderID int64 `db:"sender_id"`
|
||||
Subject string `db:"subject"`
|
||||
Content string `db:"content"`
|
||||
Anonymous bool `db:"anonymous"`
|
||||
AttachmentFileIDs string `db:"attachment_file_ids"`
|
||||
NoticeVersion string `db:"notice_version"`
|
||||
Status string `db:"status"`
|
||||
SubmittedAt time.Time `db:"submitted_at"`
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"channel_code"`
|
||||
Name string `db:"name"`
|
||||
ChannelType int `db:"channel_type"`
|
||||
MinAmount int64 `db:"min_amount"`
|
||||
MaxAmount int64 `db:"max_amount"`
|
||||
FeeType int `db:"fee_type"`
|
||||
FeeFixedAmount int64 `db:"fee_fixed_amount"`
|
||||
FeeRate int `db:"fee_rate"`
|
||||
}
|
||||
|
||||
type WithdrawalWallet struct {
|
||||
AccountID int64 `db:"account_id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
AccountStatus int `db:"account_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type WithdrawalOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
WithdrawalNo string `db:"withdrawal_no"`
|
||||
PaymentAccountID int64 `db:"payment_account_id"`
|
||||
ChannelID int64 `db:"channel_id"`
|
||||
ChannelName string `db:"channel_name"`
|
||||
RequestAmount int64 `db:"request_amount"`
|
||||
FeeAmount int64 `db:"fee_amount"`
|
||||
ActualAmount int64 `db:"actual_amount"`
|
||||
Currency string `db:"currency"`
|
||||
AccountMask string `db:"account_mask"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
RiskResult json.RawMessage `db:"risk_result"`
|
||||
Status int `db:"withdrawal_status"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
ExternalNo string `db:"external_no"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
ProcessedAt string `db:"processed_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
Replayed bool `db:"-"`
|
||||
}
|
||||
|
||||
type WithdrawalPage struct {
|
||||
Items []WithdrawalOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
ORDER BY is_default ASC,id DESC`, tenantID, memberID)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
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 accountCount int
|
||||
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
|
||||
WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isDefault := 2
|
||||
if accountCount == 0 {
|
||||
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,
|
||||
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,
|
||||
isDefault, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPaymentAccountExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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,
|
||||
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`,
|
||||
tenantID, memberID, accountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrPaymentAccountInvalid
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveWithdrawalChannel(ctx context.Context, tenantID int64, channelTypes []int) (*WithdrawalChannel, error) {
|
||||
if len(channelTypes) == 0 {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
|
||||
WHERE tenant_id=? AND channel_type IN (?) AND direction IN (2,3) AND status=1 AND deleted_at IS NULL
|
||||
ORDER BY CASE WHEN channel_type=? THEN 0 ELSE 1 END,sort,id LIMIT 1`, tenantID, channelTypes, channelTypes[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row WithdrawalChannel
|
||||
err = s.db.GetContext(ctx, &row, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalWallet(ctx context.Context, tenantID, memberID int64) (*WithdrawalWallet, error) {
|
||||
var row WithdrawalWallet
|
||||
err := s.db.GetContext(ctx, &row, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
|
||||
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency='CNY' AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation) (*WithdrawalOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, findErr := withdrawalByNo(ctx, tx, input.TenantID, input.MemberID, input.WithdrawalNo); findErr == nil {
|
||||
if existing.PaymentAccountID != input.PaymentAccountID || existing.RequestAmount != input.RequestAmount || existing.Currency != input.Currency {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
existing.Replayed = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(findErr, ErrWithdrawalNotFound) {
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
return nil, ErrPaymentAccountInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
|
||||
AND direction IN (2,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.Channel.ID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel != input.Channel {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
|
||||
return nil, ErrWithdrawalAmount
|
||||
}
|
||||
|
||||
var wallet WithdrawalWallet
|
||||
err = tx.GetContext(ctx, &wallet, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
|
||||
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.Currency)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.AccountStatus != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
|
||||
return nil, ErrWithdrawalLimit
|
||||
}
|
||||
if wallet.AvailableAmount < input.RequestAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
|
||||
riskResult := json.RawMessage(`{"result":"passed","mode":"sandbox"}`)
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_withdrawal_order
|
||||
(tenant_id,withdrawal_no,member_id,payment_account_id,channel_id,request_amount,fee_amount,
|
||||
actual_amount,currency,account_snapshot_cipher,account_mask,bank_name,holder_mask,risk_result,
|
||||
withdrawal_status,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, input.TenantID, input.WithdrawalNo, input.MemberID,
|
||||
input.PaymentAccountID, channel.ID, input.RequestAmount, input.FeeAmount, input.ActualAmount,
|
||||
input.Currency, input.AccountSnapshotCipher, input.AccountMask, input.BankName, input.HolderMask,
|
||||
riskResult, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
orderID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
beforeAvailable := wallet.AvailableAmount
|
||||
afterAvailable := beforeAvailable - input.RequestAmount
|
||||
beforeFrozen := wallet.FrozenAmount
|
||||
afterFrozen := beforeFrozen + input.RequestAmount
|
||||
update, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
|
||||
updated_at=UTC_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND available_amount=? AND frozen_amount=? AND account_status=1`,
|
||||
afterAvailable, afterFrozen, wallet.AccountID, input.TenantID, beforeAvailable, beforeFrozen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := update.RowsAffected(); affected != 1 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
availableLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.AvailableLedgerNo,
|
||||
"available", 2, beforeAvailable, afterAvailable, "available", "取出申请冻结可用余额")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = availableLedgerID
|
||||
frozenLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.FrozenLedgerNo,
|
||||
"frozen", 1, beforeFrozen, afterFrozen, "frozen", "取出申请增加冻结余额")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE hg_withdrawal_order SET freeze_ledger_id=? WHERE tenant_id=? AND id=?`,
|
||||
frozenLedgerID, input.TenantID, orderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WithdrawalOrder{ID: orderID, WithdrawalNo: input.WithdrawalNo,
|
||||
PaymentAccountID: input.PaymentAccountID, ChannelID: channel.ID, ChannelName: channel.Name,
|
||||
RequestAmount: input.RequestAmount, FeeAmount: input.FeeAmount, ActualAmount: input.ActualAmount,
|
||||
Currency: input.Currency, AccountMask: input.AccountMask, BankName: input.BankName,
|
||||
HolderMask: input.HolderMask, RiskResult: riskResult, Status: 1}, nil
|
||||
}
|
||||
|
||||
func insertWithdrawalLedger(ctx context.Context, tx *sqlx.Tx, input WithdrawalCreation, accountID int64,
|
||||
ledgerNo, entryType string, direction int, beforeAmount, afterAmount int64, suffix, 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(?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,UTC_TIMESTAMP(3))`, input.TenantID, ledgerNo, accountID,
|
||||
input.MemberID, "withdraw_freeze", input.WithdrawalNo, entryType, direction, input.RequestAmount,
|
||||
beforeAmount, afterAmount, input.Currency, "withdrawal:"+input.WithdrawalNo+":"+suffix, remark, input.MemberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*WithdrawalPage, error) {
|
||||
where := ` WHERE o.tenant_id=? AND o.member_id=?`
|
||||
args := []any{tenantID, memberID}
|
||||
if status > 0 {
|
||||
where += ` AND o.withdrawal_status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_withdrawal_order o`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
items := make([]WithdrawalOrder, 0)
|
||||
err := s.db.SelectContext(ctx, &items, withdrawalSelectSQL()+where+
|
||||
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
return &WithdrawalPage{Items: items, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*WithdrawalOrder, error) {
|
||||
var row WithdrawalOrder
|
||||
err := s.db.GetContext(ctx, &row, withdrawalSelectSQL()+
|
||||
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func withdrawalByNo(ctx context.Context, db queryer, tenantID, memberID int64, no string) (*WithdrawalOrder, error) {
|
||||
query := withdrawalSelectSQL() + ` WHERE o.tenant_id=? AND o.member_id=? AND o.withdrawal_no=? LIMIT 1`
|
||||
var row WithdrawalOrder
|
||||
err := db.GetContext(ctx, &row, query, tenantID, memberID, no)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func withdrawalSelectSQL() string {
|
||||
return `SELECT o.id,o.withdrawal_no,o.payment_account_id,o.channel_id,COALESCE(c.name,'') AS channel_name,
|
||||
o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask,
|
||||
COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code,
|
||||
o.review_reason,o.external_no,
|
||||
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
|
||||
}
|
||||
|
||||
func maxInt64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package tableparse
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSampleRows = 20
|
||||
maxRows = 5000
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Columns []ColumnStat `json:"columns"`
|
||||
SampleRows [][]string `json:"sample_rows"`
|
||||
RowCount int `json:"row_count"`
|
||||
Stats map[string]NumericStat `json:"stats,omitempty"`
|
||||
Outliers []Outlier `json:"outliers,omitempty"`
|
||||
Parsed bool `json:"parsed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ColumnStat struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Empty int `json:"empty"`
|
||||
}
|
||||
|
||||
type NumericStat struct {
|
||||
Count int `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
Avg float64 `json:"avg"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
}
|
||||
|
||||
type Outlier struct {
|
||||
Row int `json:"row"`
|
||||
Column string `json:"column"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func Parse(path, fileType string) (Profile, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(fileType)) {
|
||||
case "csv":
|
||||
return parseCSV(path)
|
||||
case "xlsx":
|
||||
return parseXLSX(path)
|
||||
case "xls":
|
||||
return Profile{Parsed: false, Message: "legacy xls parsing is not supported yet"}, nil
|
||||
default:
|
||||
return Profile{}, fmt.Errorf("unsupported table file type: %s", fileType)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCSV(path string) (Profile, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
reader := csv.NewReader(f)
|
||||
reader.FieldsPerRecord = -1
|
||||
rows := make([][]string, 0, maxRows)
|
||||
for len(rows) < maxRows {
|
||||
row, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if emptyRow(row) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, trimRow(row))
|
||||
}
|
||||
return profileRows(rows), nil
|
||||
}
|
||||
|
||||
func parseXLSX(path string) (Profile, error) {
|
||||
f, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
iter, err := f.Rows(sheet)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned := make([][]string, 0, maxRows)
|
||||
for iter.Next() {
|
||||
row, err := iter.Columns()
|
||||
if err != nil {
|
||||
_ = iter.Close()
|
||||
return Profile{}, err
|
||||
}
|
||||
if emptyRow(row) {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimRow(row))
|
||||
if len(cleaned) >= maxRows {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := iter.Close(); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
return profileRows(cleaned), nil
|
||||
}
|
||||
}
|
||||
return Profile{Parsed: false, Message: "no non-empty sheet found"}, nil
|
||||
}
|
||||
|
||||
func profileRows(rows [][]string) Profile {
|
||||
if len(rows) == 0 {
|
||||
return Profile{Parsed: false, Message: "empty table"}
|
||||
}
|
||||
headers := rows[0]
|
||||
if len(headers) == 0 {
|
||||
return Profile{Parsed: false, Message: "empty header"}
|
||||
}
|
||||
dataRows := rows[1:]
|
||||
columnCount := len(headers)
|
||||
columns := make([]ColumnStat, columnCount)
|
||||
statsWork := make([]numericWork, columnCount)
|
||||
for i, name := range headers {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("列%d", i+1)
|
||||
}
|
||||
columns[i] = ColumnStat{Name: name, Type: "text"}
|
||||
}
|
||||
sample := make([][]string, 0, min(len(dataRows), maxSampleRows))
|
||||
for rowIndex, row := range dataRows {
|
||||
normalized := normalizeWidth(row, columnCount)
|
||||
if len(sample) < maxSampleRows {
|
||||
sample = append(sample, normalized)
|
||||
}
|
||||
for col := 0; col < columnCount; col++ {
|
||||
value := strings.TrimSpace(normalized[col])
|
||||
if value == "" {
|
||||
columns[col].Empty++
|
||||
continue
|
||||
}
|
||||
if parsed, ok := parseNumber(value); ok {
|
||||
statsWork[col].add(parsed, rowIndex+2)
|
||||
}
|
||||
}
|
||||
}
|
||||
stats := map[string]NumericStat{}
|
||||
outliers := []Outlier{}
|
||||
for i := range columns {
|
||||
work := statsWork[i]
|
||||
nonEmpty := len(dataRows) - columns[i].Empty
|
||||
if work.Count > 0 && work.Count*2 >= max(1, nonEmpty) {
|
||||
columns[i].Type = "number"
|
||||
stat := work.stat()
|
||||
stats[columns[i].Name] = stat
|
||||
if stat.Count >= 3 {
|
||||
for _, point := range work.Points {
|
||||
if point.Value == stat.Max && stat.Avg > 0 && point.Value > stat.Avg*2 {
|
||||
outliers = append(outliers, Outlier{Row: point.Row, Column: columns[i].Name, Description: "数值明显高于平均值"})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Profile{Columns: columns, SampleRows: sample, RowCount: len(dataRows), Stats: stats, Outliers: outliers, Parsed: true}
|
||||
}
|
||||
|
||||
type numericWork struct {
|
||||
Count int
|
||||
Sum float64
|
||||
Min float64
|
||||
Max float64
|
||||
Points []numericPoint
|
||||
}
|
||||
|
||||
type numericPoint struct {
|
||||
Row int
|
||||
Value float64
|
||||
}
|
||||
|
||||
func (w *numericWork) add(value float64, row int) {
|
||||
if w.Count == 0 {
|
||||
w.Min = value
|
||||
w.Max = value
|
||||
} else {
|
||||
w.Min = math.Min(w.Min, value)
|
||||
w.Max = math.Max(w.Max, value)
|
||||
}
|
||||
w.Count++
|
||||
w.Sum += value
|
||||
if len(w.Points) < 200 {
|
||||
w.Points = append(w.Points, numericPoint{Row: row, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
func (w numericWork) stat() NumericStat {
|
||||
avg := 0.0
|
||||
if w.Count > 0 {
|
||||
avg = w.Sum / float64(w.Count)
|
||||
}
|
||||
return NumericStat{Count: w.Count, Sum: w.Sum, Avg: avg, Min: w.Min, Max: w.Max}
|
||||
}
|
||||
|
||||
func parseNumber(value string) (float64, bool) {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, ",", ""))
|
||||
value = strings.TrimSuffix(value, "%")
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func normalizeWidth(row []string, width int) []string {
|
||||
out := make([]string, width)
|
||||
for i := 0; i < width && i < len(row); i++ {
|
||||
out[i] = strings.TrimSpace(row[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimRow(row []string) []string {
|
||||
out := make([]string, len(row))
|
||||
for i := range row {
|
||||
out[i] = strings.TrimSpace(row[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyRow(row []string) bool {
|
||||
for _, value := range row {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package tableparse
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestParseCSVBuildsProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sales.csv")
|
||||
if err := os.WriteFile(path, []byte("date,sales,orders\n8/1,100,2\n8/2,200,4\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile, err := Parse(path, "csv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !profile.Parsed || profile.RowCount != 2 {
|
||||
t.Fatalf("profile parsed=%v rowCount=%d, want parsed=true rowCount=2", profile.Parsed, profile.RowCount)
|
||||
}
|
||||
if got := profile.Stats["sales"].Sum; got != 300 {
|
||||
t.Fatalf("sales sum = %v, want 300", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXLSXBuildsProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sales.xlsx")
|
||||
f := excelize.NewFile()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &[]any{"date", "sales"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &[]any{"8/1", 120}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile, err := Parse(path, "xlsx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !profile.Parsed || profile.Columns[1].Type != "number" {
|
||||
t.Fatalf("profile parsed=%v sales type=%q, want parsed=true number", profile.Parsed, profile.Columns[1].Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tasks
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrHallIDInvalid = errors.New("hall id invalid")
|
||||
ErrOrderIDInvalid = errors.New("order id invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
ErrStatusInvalid = errors.New("status invalid")
|
||||
ErrEvidenceInvalid = errors.New("evidence invalid")
|
||||
ErrTaskDisabled = errors.New("task feature disabled")
|
||||
ErrOutsideBusinessHour = errors.New("outside business hours")
|
||||
)
|
||||
@@ -0,0 +1,553 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
defaultDeadlineMinute = 15
|
||||
)
|
||||
|
||||
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
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)
|
||||
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)
|
||||
SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error)
|
||||
OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Hall struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
Summary string `json:"summary"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
RateUnit string `json:"rate_unit"`
|
||||
ClientRoute string `json:"client_route"`
|
||||
}
|
||||
|
||||
type Eligibility struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
MinimumAmount int64 `json:"minimum_amount"`
|
||||
TodayCount int `json:"today_count"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
PendingOrderID int64 `json:"pending_order_id"`
|
||||
BusinessStartAt string `json:"business_start_at"`
|
||||
BusinessEndAt string `json:"business_end_at"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID int64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
Product Product `json:"product"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPriceAmount int64 `json:"unit_price_amount"`
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
CommissionAmount int64 `json:"commission_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
Message string `json:"message"`
|
||||
DeadlineAt string `json:"deadline_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Task *TaskDetail `json:"task,omitempty"`
|
||||
RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"`
|
||||
Submission *Submission `json:"submission,omitempty"`
|
||||
Timeline []TimelineEntry `json:"timeline,omitempty"`
|
||||
Ledgers []map[string]any `json:"ledgers,omitempty"`
|
||||
AllowedActions []string `json:"allowed_actions,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DetailHTML string `json:"detail_html"`
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
SubmissionNo string `json:"submission_no"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
EvidenceText string `json:"evidence_text"`
|
||||
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
||||
ReviewReasonCode string `json:"review_reason_code"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
AllowResubmit bool `json:"allow_resubmit"`
|
||||
SubmittedAt string `json:"submitted_at"`
|
||||
ReviewedAt string `json:"reviewed_at"`
|
||||
SettledAt string `json:"settled_at"`
|
||||
}
|
||||
|
||||
type TimelineEntry struct {
|
||||
Status string `json:"status"`
|
||||
Text string `json:"text"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type OrderPage struct {
|
||||
Items []Order `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
TotalAssetAmount int64 `json:"total_asset_amount"`
|
||||
TodayCommissionAmount int64 `json:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"`
|
||||
TodayClaimCount int `json:"today_claim_count"`
|
||||
TodayCompletedCount int `json:"today_completed_count"`
|
||||
}
|
||||
|
||||
type SubmitInput struct {
|
||||
EvidenceText string `json:"evidence_text"`
|
||||
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
||||
}
|
||||
|
||||
type SubmitResult struct {
|
||||
SubmissionNo string `json:"submission_no"`
|
||||
Status string `json:"status"`
|
||||
SubmittedAt string `json:"submitted_at"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
}
|
||||
|
||||
type taskPolicy struct {
|
||||
Enabled bool
|
||||
MinimumAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) {
|
||||
rows, err := s.repository.TaskHalls(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Hall, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
||||
Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) {
|
||||
if hallID <= 0 {
|
||||
return nil, ErrHallIDInvalid
|
||||
}
|
||||
policy, err := s.policy(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
|
||||
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
|
||||
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"}
|
||||
switch {
|
||||
case !policy.Enabled:
|
||||
result.reject("TASK_DISABLED", "任务功能未开启")
|
||||
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
|
||||
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
|
||||
case row.MemberStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case !row.HallEligible:
|
||||
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
||||
case row.WalletStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case row.AvailableAmount < policy.MinimumAmount:
|
||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
||||
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
||||
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
||||
case row.PendingOrderID > 0:
|
||||
result.reject("PENDING_ORDER_EXISTS", "存在未完成订单")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Eligibility) reject(reason, message string) {
|
||||
e.Eligible = false
|
||||
e.Reason = reason
|
||||
e.Message = message
|
||||
}
|
||||
|
||||
func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) {
|
||||
if hallID <= 0 {
|
||||
return nil, ErrHallIDInvalid
|
||||
}
|
||||
policy, err := s.policy(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !policy.Enabled {
|
||||
return nil, ErrTaskDisabled
|
||||
}
|
||||
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
|
||||
return nil, ErrOutsideBusinessHour
|
||||
}
|
||||
now := s.now().UTC()
|
||||
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
|
||||
HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := orderFromStore(*row, false)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
statusCode, ok := orderStatusCode(status)
|
||||
if !ok {
|
||||
return nil, ErrStatusInvalid
|
||||
}
|
||||
rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Order, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, orderFromStore(row, false))
|
||||
}
|
||||
return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
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
|
||||
}
|
||||
result := orderFromStore(*row, true)
|
||||
result.Ledgers = ledgers
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) {
|
||||
row, err := s.repository.TaskStats(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
||||
TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount,
|
||||
YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount,
|
||||
TodayCompletedCount: row.TodayCompletedCount}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
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) {
|
||||
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})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
||||
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) {
|
||||
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return taskPolicy{}, err
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes"))
|
||||
if minutes <= 0 || minutes > 24*60 {
|
||||
minutes = defaultDeadlineMinute
|
||||
}
|
||||
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00")
|
||||
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"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
func orderFromStore(row store.TaskOrder, detail bool) Order {
|
||||
status := orderStatusName(row.OrderStatus)
|
||||
result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID,
|
||||
Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity,
|
||||
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
|
||||
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
|
||||
DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
if !detail {
|
||||
return result
|
||||
}
|
||||
result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML}
|
||||
result.RuleSnapshot = row.RuleSnapshot
|
||||
result.Timeline = orderTimeline(row)
|
||||
result.AllowedActions = allowedActions(row)
|
||||
if row.SubmissionNo != "" {
|
||||
ids := make([]int64, 0)
|
||||
_ = json.Unmarshal(row.EvidenceIDs, &ids)
|
||||
result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus),
|
||||
StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText,
|
||||
EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason,
|
||||
AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func orderTimeline(row store.TaskOrder) []TimelineEntry {
|
||||
items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}}
|
||||
if row.MatchedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt})
|
||||
}
|
||||
if row.SubmittedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt})
|
||||
}
|
||||
if row.ReviewedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt})
|
||||
}
|
||||
if row.SettledAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt})
|
||||
}
|
||||
if row.CompletedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func allowedActions(row store.TaskOrder) []string {
|
||||
switch row.OrderStatus {
|
||||
case 2:
|
||||
return []string{"submit", "contact_support"}
|
||||
case 3, 5, 6, 8:
|
||||
return []string{"contact_support"}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func orderStatusCode(value string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return 0, true
|
||||
case "matching":
|
||||
return 1, true
|
||||
case "pending":
|
||||
return 2, true
|
||||
case "processing":
|
||||
return 3, true
|
||||
case "completed":
|
||||
return 4, true
|
||||
case "expired":
|
||||
return 5, true
|
||||
case "frozen":
|
||||
return 6, true
|
||||
case "cancelled":
|
||||
return 7, true
|
||||
case "failed":
|
||||
return 8, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func orderStatusName(status int) string {
|
||||
names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"}
|
||||
if value, ok := names[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func orderStatusText(status int) string {
|
||||
names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"}
|
||||
if value, ok := names[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func submissionStatusName(status int) string {
|
||||
names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"}
|
||||
return names[status]
|
||||
}
|
||||
|
||||
func submissionStatusText(status int) string {
|
||||
names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"}
|
||||
return names[status]
|
||||
}
|
||||
|
||||
func withinBusinessHours(now time.Time, start, end string) bool {
|
||||
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
|
||||
startSecond := parseClock(start)
|
||||
endSecond := parseClock(end)
|
||||
if startSecond <= endSecond {
|
||||
return current >= startSecond && current <= endSecond
|
||||
}
|
||||
return current >= startSecond || current <= endSecond
|
||||
}
|
||||
|
||||
func parseClock(value string) int {
|
||||
parsed, err := time.Parse("15:04:05", value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
|
||||
}
|
||||
|
||||
func clockValue(value any, fallback string) string {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
for _, layout := range []string{"15:04:05", "15:04"} {
|
||||
if parsed, err := time.Parse(layout, raw); err == nil {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func findValue(config map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
if found := findValue(nested, keys...); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value any) int {
|
||||
return int(int64Value(value))
|
||||
}
|
||||
|
||||
func int64Value(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func max64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func validUniqueIDs(ids []int64) bool {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return false
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return false
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newNumber(prefix string, now time.Time) string {
|
||||
bytes := make([]byte, 4)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000)
|
||||
}
|
||||
return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
eligibility *store.TaskEligibility
|
||||
order *store.TaskOrder
|
||||
claimed store.ClaimTaskInput
|
||||
submitted store.TaskSubmissionInput
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return f.config, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) {
|
||||
return []store.TaskHall{{ID: 1, Code: "DEFAULT", Name: "任务大厅"}}, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) {
|
||||
return f.eligibility, nil
|
||||
}
|
||||
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
||||
f.claimed = input
|
||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
|
||||
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, 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
|
||||
}
|
||||
func (f *fakeRepository) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) {
|
||||
return f.order, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) {
|
||||
return &store.TaskStats{AvailableAmount: 100, FrozenAmount: 25}, nil
|
||||
}
|
||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||
f.submitted = input
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
|
||||
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) {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||
|
||||
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 {
|
||||
t.Fatalf("unexpected eligibility: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
||||
service := New(repository)
|
||||
|
||||
_, err := service.Claim(context.Background(), 0, 10, 1)
|
||||
if !errors.Is(err, ErrTaskDisabled) {
|
||||
t.Fatalf("expected task disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
result, err := service.Claim(context.Background(), 0, 10, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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) {
|
||||
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}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
|
||||
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||
if !errors.Is(err, ErrStatusInvalid) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user