feat: migrate workbench services

This commit is contained in:
richjohnrambo
2026-10-10 23:34:06 +08:00
parent c13fb72849
commit 81e3cf1d03
88 changed files with 7358 additions and 15333 deletions
+123
View File
@@ -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)
}
}
+460
View File
@@ -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"
}
+100
View File
@@ -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)
}
}
}
-197
View File
@@ -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"`
}
-560
View File
@@ -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")
}
-54
View File
@@ -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
View File
@@ -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
View File
@@ -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 ""
}
+27
View File
@@ -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
View File
@@ -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))
+9 -88
View File
@@ -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")
}
}
-180
View File
@@ -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)
}
+19
View File
@@ -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")
)
+737
View File
@@ -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
}
+128
View File
@@ -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)
}
}
+144
View File
@@ -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)
}
}
-392
View File
@@ -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
-254
View File
@@ -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)
}
-55
View File
@@ -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)
}
}
}
-366
View File
@@ -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
View File
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)
}
}
-9
View File
@@ -1,9 +0,0 @@
package httpapi
import "errors"
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict")
)
+201
View File
@@ -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)
}
}
-72
View File
@@ -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
}
-51
View File
@@ -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())
}
}
+104
View File
@@ -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)
}
}
-547
View File
@@ -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")
}
-171
View File
@@ -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")
}
+139
View File
@@ -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)
}
}
+160
View File
@@ -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)
}
}
+19 -49
View File
@@ -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)
}
+231
View File
@@ -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)
}
}
-142
View File
@@ -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
}
-47
View File
@@ -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")
}
}
+10
View File
@@ -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")
)
+149
View File
@@ -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()
}
+61
View File
@@ -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)
}
}
-235
View File
@@ -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
}
-83
View File
@@ -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))))
}
+8
View File
@@ -0,0 +1,8 @@
package portal
import "errors"
var (
ErrPositionInvalid = errors.New("banner position invalid")
ErrContentKeyInvalid = errors.New("content key invalid")
)
+249
View File
@@ -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"
}
+128
View File
@@ -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)
}
}
}
+175
View File
@@ -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)
}
-306
View File
@@ -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
}
-603
View File
@@ -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
}
-116
View File
@@ -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
}
-23
View File
@@ -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)
}
})
}
}
+28 -4
View File
@@ -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")
)
+78
View File
@@ -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`
}
-94
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -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
}
+161
View File
@@ -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
}
+480
View File
@@ -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()
}
-115
View File
@@ -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
}
-234
View File
@@ -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"`
}
+477
View File
@@ -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
}
-262
View File
@@ -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
}
-49
View File
@@ -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)
}
}
+14
View File
@@ -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")
)
+553
View File
@@ -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))
}
+117
View File
@@ -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)
}
}