feat: 初始提交云印享付费打印服务后端
This commit is contained in:
105
cloudprint-backend/pkg/jwt/jwt.go
Normal file
105
cloudprint-backend/pkg/jwt/jwt.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrExpiredToken = errors.New("token has expired")
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int32 `json:"user_id"`
|
||||
UserType string `json:"user_type"` // "user" or "admin"
|
||||
Username string `json:"username,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type JWTManager struct {
|
||||
secretKey []byte
|
||||
accessTokenTTL time.Duration
|
||||
refreshTokenTTL time.Duration
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, accessTokenTTL, refreshTokenTTL time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secretKey: []byte(secret),
|
||||
accessTokenTTL: accessTokenTTL,
|
||||
refreshTokenTTL: refreshTokenTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *JWTManager) GenerateAccessToken(userID int32, userType string, username string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
UserType: userType,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secretKey)
|
||||
}
|
||||
|
||||
func (m *JWTManager) GenerateRefreshToken(userID int32, userType string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
UserType: userType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Subject: "refresh",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secretKey)
|
||||
}
|
||||
|
||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return m.secretKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (m *JWTManager) RefreshAccessToken(refreshToken string) (string, error) {
|
||||
claims, err := m.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Only refresh tokens with subject "refresh" can be used
|
||||
if claims.Subject != "refresh" {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
|
||||
return m.GenerateAccessToken(claims.UserID, claims.UserType, claims.Username)
|
||||
}
|
||||
200
cloudprint-backend/pkg/pay/wechat.go
Normal file
200
cloudprint-backend/pkg/pay/wechat.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudprint/cloudprint-backend/config"
|
||||
"github.com/iGoogle-ink/gopay"
|
||||
)
|
||||
|
||||
// RandomString generates a random string with given length
|
||||
func RandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type WeChatPay struct {
|
||||
client *gopay.WxPayClient
|
||||
config *config.WeChatConfig
|
||||
}
|
||||
|
||||
type UnifiedOrderResponse struct {
|
||||
ReturnCode string `xml:"return_code"`
|
||||
ReturnMsg string `xml:"return_msg"`
|
||||
ResultCode string `xml:"result_code"`
|
||||
PrepayID string `xml:"prepay_id,omitempty"`
|
||||
CodeURL string `xml:"code_url,omitempty"`
|
||||
MWebURL string `xml:"mweb_url,omitempty"`
|
||||
ErrCode string `xml:"err_code,omitempty"`
|
||||
ErrMsg string `xml:"err_msg,omitempty"`
|
||||
TransactionID string `xml:"transaction_id,omitempty"`
|
||||
TradeState string `xml:"trade_state,omitempty"`
|
||||
TotalFee int `xml:"total_fee,omitempty"`
|
||||
}
|
||||
|
||||
type PayCallback struct {
|
||||
ReturnCode string `xml:"return_code"`
|
||||
ReturnMsg string `xml:"return_msg"`
|
||||
AppID string `xml:"appid"`
|
||||
MchID string `xml:"mch_id"`
|
||||
NonceStr string `xml:"nonce_str"`
|
||||
Sign string `xml:"sign"`
|
||||
ResultCode string `xml:"result_code"`
|
||||
TransactionID string `xml:"transaction_id"`
|
||||
OutTradeNo string `xml:"out_trade_no"`
|
||||
TotalFee int `xml:"total_fee"`
|
||||
TimeEnd string `xml:"time_end"`
|
||||
}
|
||||
|
||||
func NewWeChatPay(cfg *config.WeChatConfig) *WeChatPay {
|
||||
client := gopay.NewWxPayClient(cfg.AppID, cfg.MchID, cfg.ApiKey, cfg.NotifyURL)
|
||||
if cfg.CertPath != "" {
|
||||
client.SetCertFilePath(cfg.CertPath)
|
||||
}
|
||||
return &WeChatPay{
|
||||
client: client,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WeChatPay) createSign(params map[string]string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var signParts []string
|
||||
for _, k := range keys {
|
||||
if params[k] != "" && k != "sign" {
|
||||
signParts = append(signParts, fmt.Sprintf("%s=%s", k, params[k]))
|
||||
}
|
||||
}
|
||||
signStr := strings.Join(signParts, "&")
|
||||
signStr += "&key=" + p.config.ApiKey
|
||||
|
||||
md5Hash := md5.New()
|
||||
md5Hash.Write([]byte(signStr))
|
||||
return strings.ToUpper(hex.EncodeToString(md5Hash.Sum(nil)))
|
||||
}
|
||||
|
||||
func (p *WeChatPay) UnifiedOrder(ctx context.Context, orderNo string, amount float64, description, openID, clientIP string) (*UnifiedOrderResponse, error) {
|
||||
nonceStr := RandomString(32)
|
||||
totalFee := int(amount * 100) // Convert to fen
|
||||
|
||||
// Build request body
|
||||
body := gopay.UnifiedOrder{
|
||||
AppID: p.config.AppID,
|
||||
MchID: p.config.MchID,
|
||||
NonceStr: nonceStr,
|
||||
Body: description,
|
||||
OutTradeNo: orderNo,
|
||||
TotalFee: totalFee,
|
||||
SpbillCreateIP: clientIP,
|
||||
NotifyURL: p.config.NotifyURL,
|
||||
TradeType: "JSAPI",
|
||||
OpenID: openID,
|
||||
}
|
||||
|
||||
// Use go-pay client to call unified order
|
||||
resp, err := p.client.UnifiedOrder(ctx, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unified order failed: %w", err)
|
||||
}
|
||||
|
||||
result := &UnifiedOrderResponse{
|
||||
ReturnCode: resp.GetString("return_code"),
|
||||
ReturnMsg: resp.GetString("return_msg"),
|
||||
ResultCode: resp.GetString("result_code"),
|
||||
PrepayID: resp.GetString("prepay_id"),
|
||||
CodeURL: resp.GetString("code_url"),
|
||||
MWebURL: resp.GetString("mweb_url"),
|
||||
ErrCode: resp.GetString("err_code"),
|
||||
ErrMsg: resp.GetString("err_msg"),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *WeChatPay) QueryOrder(ctx context.Context, orderNo string) (string, int, error) {
|
||||
nonceStr := RandomString(32)
|
||||
|
||||
body := gopay.QueryOrder{
|
||||
AppID: p.config.AppID,
|
||||
MchID: p.config.MchID,
|
||||
NonceStr: nonceStr,
|
||||
OutTradeNo: orderNo,
|
||||
}
|
||||
|
||||
resp, err := p.client.QueryOrder(ctx, body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
tradeState := resp.GetString("trade_state")
|
||||
totalFee := resp.GetInt("total_fee")
|
||||
|
||||
return tradeState, totalFee, nil
|
||||
}
|
||||
|
||||
func (p *WeChatPay) ParseCallback(body []byte) (*PayCallback, error) {
|
||||
var callback PayCallback
|
||||
if err := xml.Unmarshal(body, &callback); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse callback: %w", err)
|
||||
}
|
||||
return &callback, nil
|
||||
}
|
||||
|
||||
func (p *WeChatPay) ValidateCallback(callback *PayCallback) bool {
|
||||
params := map[string]string{
|
||||
"return_code": callback.ReturnCode,
|
||||
"return_msg": callback.ReturnMsg,
|
||||
"appid": callback.AppID,
|
||||
"mch_id": callback.MchID,
|
||||
"nonce_str": callback.NonceStr,
|
||||
"result_code": callback.ResultCode,
|
||||
"transaction_id": callback.TransactionID,
|
||||
"out_trade_no": callback.OutTradeNo,
|
||||
"total_fee": fmt.Sprintf("%d", callback.TotalFee),
|
||||
"time_end": callback.TimeEnd,
|
||||
}
|
||||
|
||||
expectedSign := p.createSign(params)
|
||||
return callback.Sign == expectedSign
|
||||
}
|
||||
|
||||
func (p *WeChatPay) GenerateJSAPIPayParams(prepayID string) (map[string]string, error) {
|
||||
nonceStr := RandomString(32)
|
||||
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||
|
||||
params := map[string]string{
|
||||
"appId": p.config.AppID,
|
||||
"timeStamp": timestamp,
|
||||
"nonceStr": nonceStr,
|
||||
"package": "prepay_id=" + prepayID,
|
||||
"signType": "MD5",
|
||||
}
|
||||
|
||||
paySign := p.createSign(map[string]string{
|
||||
"appId": p.config.AppID,
|
||||
"timeStamp": timestamp,
|
||||
"nonceStr": nonceStr,
|
||||
"package": "prepay_id=" + prepayID,
|
||||
"signType": "MD5",
|
||||
})
|
||||
|
||||
params["paySign"] = paySign
|
||||
return params, nil
|
||||
}
|
||||
196
cloudprint-backend/pkg/upload/upload.go
Normal file
196
cloudprint-backend/pkg/upload/upload.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
// AllowedFileTypes 文件类型白名单
|
||||
AllowedFileTypes = map[string]bool{
|
||||
"pdf": true,
|
||||
"doc": true,
|
||||
"docx": true,
|
||||
"xls": true,
|
||||
"xlsx": true,
|
||||
"ppt": true,
|
||||
"pptx": true,
|
||||
"txt": true,
|
||||
"jpg": true,
|
||||
"jpeg": true,
|
||||
"png": true,
|
||||
"bmp": true,
|
||||
"gif": true,
|
||||
"zip": true,
|
||||
"rar": true,
|
||||
}
|
||||
)
|
||||
|
||||
type FileUploader struct {
|
||||
basePath string
|
||||
chunkPath string
|
||||
maxSize int64
|
||||
mu sync.RWMutex
|
||||
chunks map[string]*ChunkInfo
|
||||
}
|
||||
|
||||
type ChunkInfo struct {
|
||||
FileName string
|
||||
TotalParts int
|
||||
Parts map[int][]byte
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func NewFileUploader(basePath string, chunkPath string, maxSize int64) *FileUploader {
|
||||
uploader := &FileUploader{
|
||||
basePath: basePath,
|
||||
chunkPath: chunkPath,
|
||||
maxSize: maxSize,
|
||||
chunks: make(map[string]*ChunkInfo),
|
||||
}
|
||||
// Ensure directories exist
|
||||
os.MkdirAll(basePath, 0755)
|
||||
os.MkdirAll(chunkPath, 0755)
|
||||
return uploader
|
||||
}
|
||||
|
||||
func (u *FileUploader) getExt(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
func (u *FileUploader) ValidateFileType(filename string) bool {
|
||||
ext := u.getExt(filename)
|
||||
return AllowedFileTypes[ext]
|
||||
}
|
||||
|
||||
func (u *FileUploader) GenerateFilePath(filename string) string {
|
||||
ext := u.getExt(filename)
|
||||
newName := fmt.Sprintf("%s.%s", uuid.New().String(), ext)
|
||||
// 按日期分目录
|
||||
// dateDir := time.Now().Format("2006/01/02")
|
||||
// return filepath.Join(dateDir, newName)
|
||||
return newName
|
||||
}
|
||||
|
||||
func (u *FileUploader) Upload(ctx context.Context, file *multipart.FileHeader) (string, error) {
|
||||
if u.maxSize > 0 && file.Size > u.maxSize {
|
||||
return "", fmt.Errorf("file size exceeds limit: %d > %d", file.Size, u.maxSize)
|
||||
}
|
||||
|
||||
if !u.ValidateFileType(file.Filename) {
|
||||
return "", fmt.Errorf("file type not allowed: %s", file.Filename)
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
filePath := u.GenerateFilePath(file.Filename)
|
||||
fullPath := filepath.Join(u.basePath, filePath)
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// Chunk upload support
|
||||
func (u *FileUploader) InitChunkUpload(chunkID, fileName string, totalParts int) error {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
u.chunks[chunkID] = &ChunkInfo{
|
||||
FileName: fileName,
|
||||
TotalParts: totalParts,
|
||||
Parts: make(map[int][]byte),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *FileUploader) SaveChunk(chunkID string, partIndex int, data []byte) error {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
chunk, ok := u.chunks[chunkID]
|
||||
if !ok {
|
||||
return fmt.Errorf("chunk upload not initialized: %s", chunkID)
|
||||
}
|
||||
|
||||
if partIndex < 0 || partIndex >= chunk.TotalParts {
|
||||
return fmt.Errorf("invalid part index: %d", partIndex)
|
||||
}
|
||||
|
||||
chunk.Parts[partIndex] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *FileUploader) CompleteChunkUpload(ctx context.Context, chunkID string) (string, error) {
|
||||
u.mu.Lock()
|
||||
chunk, ok := u.chunks[chunkID]
|
||||
if !ok {
|
||||
u.mu.Unlock()
|
||||
return "", fmt.Errorf("chunk upload not found: %s", chunkID)
|
||||
}
|
||||
|
||||
if len(chunk.Parts) != chunk.TotalParts {
|
||||
u.mu.Unlock()
|
||||
return "", fmt.Errorf("incomplete chunks: %d/%d", len(chunk.Parts), chunk.TotalParts)
|
||||
}
|
||||
|
||||
// Delete chunk info early to allow other operations
|
||||
delete(u.chunks, chunkID)
|
||||
u.mu.Unlock()
|
||||
|
||||
filePath := u.GenerateFilePath(chunk.FileName)
|
||||
fullPath := filepath.Join(u.basePath, filePath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for i := 0; i < chunk.TotalParts; i++ {
|
||||
if _, err := f.Write(chunk.Parts[i]); err != nil {
|
||||
return "", fmt.Errorf("failed to write chunk %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (u *FileUploader) DeleteFile(filePath string) error {
|
||||
fullPath := filepath.Join(u.basePath, filePath)
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
func (u *FileUploader) GetFilePath(filePath string) string {
|
||||
return filepath.Join(u.basePath, filePath)
|
||||
}
|
||||
Reference in New Issue
Block a user