feat: 初始提交云印享付费打印服务后端

This commit is contained in:
WuYuuuub
2026-03-29 11:55:53 +08:00
parent 5047380dd0
commit 89a7c87bf6
35 changed files with 12522 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/cloudprint/cloudprint-backend/config"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
userRepo *repository.UserRepository
adminRepo *repository.AdminRepository
jwtManager *jwt.JWTManager
redis *RedisClient
wxConfig *config.WeChatConfig
}
type RedisClient struct {
// In production, use actual Redis client
}
func NewAuthService(
userRepo *repository.UserRepository,
adminRepo *repository.AdminRepository,
jwtManager *jwt.JWTManager,
redis *RedisClient,
wxConfig *config.WeChatConfig,
) *AuthService {
return &AuthService{
userRepo: userRepo,
adminRepo: adminRepo,
jwtManager: jwtManager,
redis: redis,
wxConfig: wxConfig,
}
}
// WeChat code exchange response
type WeChatCodeResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid,omitempty"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
func (s *AuthService) Login(ctx context.Context, code string) (*LoginResult, error) {
// Exchange code for openid from WeChat
openid, err := s.getWeChatOpenID(code)
if err != nil {
return nil, fmt.Errorf("failed to get wechat openid: %w", err)
}
// Find or create user
user, err := s.userRepo.GetByOpenid(ctx, openid)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
isNewUser := false
if user == nil {
isNewUser = true
user = &model.User{
Openid: openid,
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, "user", "")
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID, "user")
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
return &LoginResult{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: user,
IsNewUser: isNewUser,
}, nil
}
func (s *AuthService) getWeChatOpenID(code string) (string, error) {
url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
s.wxConfig.AppID, s.wxConfig.AppSecret, code)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result WeChatCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.ErrCode != 0 {
return "", fmt.Errorf("wechat error: %s", result.ErrMsg)
}
return result.OpenID, nil
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
return s.jwtManager.RefreshAccessToken(refreshToken)
}
func (s *AuthService) GetUserInfo(ctx context.Context, userID int32) (*model.User, error) {
return s.userRepo.GetByID(ctx, userID)
}
func (s *AuthService) UpdateUser(ctx context.Context, userID int32, nickname, avatar string) (*model.User, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil || user == nil {
return nil, errors.New("user not found")
}
user.Nickname = nickname
if avatar != "" {
user.Avatar = avatar
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
type LoginResult struct {
AccessToken string
RefreshToken string
User *model.User
IsNewUser bool
}
// Admin login
func (s *AuthService) AdminLogin(ctx context.Context, username, password string) (*AdminLoginResult, error) {
admin, err := s.adminRepo.GetByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf("failed to get admin: %w", err)
}
if admin == nil {
return nil, errors.New("invalid credentials")
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
// Generate token
accessToken, err := s.jwtManager.GenerateAccessToken(admin.ID, "admin", admin.Username)
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
return &AdminLoginResult{
AccessToken: accessToken,
Admin: admin,
}, nil
}
type AdminLoginResult struct {
AccessToken string
Admin *model.Admin
}
// HashPassword creates a bcrypt hash of the password
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}

View File

@@ -0,0 +1,105 @@
package service
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/pkg/upload"
)
type FileService struct {
fileRepo *repository.FileRepository
uploader *upload.FileUploader
uploadDir string
}
func NewFileService(fileRepo *repository.FileRepository, uploader *upload.FileUploader, uploadDir string) *FileService {
return &FileService{
fileRepo: fileRepo,
uploader: uploader,
uploadDir: uploadDir,
}
}
func (s *FileService) Upload(ctx context.Context, fileName string, fileType string, uploaderType string, uploaderID int32, data []byte) (*model.File, error) {
if !s.uploader.ValidateFileType(fileName) {
return nil, fmt.Errorf("file type not allowed")
}
// Save file
filePath := s.uploader.GenerateFilePath(fileName)
fullPath := s.uploader.GetFilePath(filePath)
if err := os.WriteFile(fullPath, data, 0644); err != nil {
return nil, fmt.Errorf("failed to write file: %w", err)
}
// Create file record
file := &model.File{
FileName: fileName,
FilePath: filePath,
FileType: strings.ToLower(fileType),
FileSize: int64(len(data)),
UploaderType: uploaderType,
UploaderID: uploaderID,
}
if err := s.fileRepo.Create(ctx, file); err != nil {
// Clean up file on error
os.Remove(fullPath)
return nil, fmt.Errorf("failed to create file record: %w", err)
}
return file, nil
}
func (s *FileService) GetFile(ctx context.Context, fileID int32) (*model.File, error) {
return s.fileRepo.GetByID(ctx, fileID)
}
func (s *FileService) GetFileReader(ctx context.Context, file *model.File) (io.ReadCloser, error) {
fullPath := filepath.Join(s.uploadDir, file.FilePath)
return os.Open(fullPath)
}
func (s *FileService) DeleteFile(ctx context.Context, fileID int32, uploaderType string, uploaderID int32) error {
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil || file == nil {
return fmt.Errorf("file not found")
}
// Verify ownership
if file.UploaderType != uploaderType || file.UploaderID != uploaderID {
return fmt.Errorf("unauthorized")
}
// Delete physical file
fullPath := s.uploader.GetFilePath(file.FilePath)
os.Remove(fullPath)
// Delete record
return s.fileRepo.Delete(ctx, fileID)
}
func (s *FileService) GetFileList(ctx context.Context, uploaderType string, uploaderID int32, page, pageSize int) ([]*model.File, int64, error) {
return s.fileRepo.GetListByUploader(ctx, uploaderType, uploaderID, page, pageSize)
}
func (s *FileService) AddWatermark(ctx context.Context, fileID int32, text string, opacity float32, position int, fontSize int) (*model.File, error) {
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil || file == nil {
return nil, fmt.Errorf("file not found")
}
// In production, use a PDF library to add watermark
// For now, just return the original file
// TODO: Implement actual watermark using pdf-lib or similar
return file, nil
}

View File

@@ -0,0 +1,218 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/google/uuid"
)
type OrderService struct {
orderRepo *repository.OrderRepository
orderItemRepo *repository.OrderItemRepository
fileRepo *repository.FileRepository
priceRepo *repository.PriceRepository
userRepo *repository.UserRepository
printerRepo *repository.PrinterRepository
printJobRepo *repository.PrintJobRepository
}
func NewOrderService(
orderRepo *repository.OrderRepository,
orderItemRepo *repository.OrderItemRepository,
fileRepo *repository.FileRepository,
priceRepo *repository.PriceRepository,
userRepo *repository.UserRepository,
printerRepo *repository.PrinterRepository,
printJobRepo *repository.PrintJobRepository,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
orderItemRepo: orderItemRepo,
fileRepo: fileRepo,
priceRepo: priceRepo,
userRepo: userRepo,
printerRepo: printerRepo,
printJobRepo: printJobRepo,
}
}
type CreateOrderItem struct {
FileID int32
PrinterID int32
Copies int
Color bool
Duplex bool
PageRange string
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int32, items []*CreateOrderItem, remark string) (*model.Order, error) {
if len(items) == 0 {
return nil, errors.New("order must have at least one item")
}
// Generate order number
orderNo := generateOrderNo()
// Calculate total amount
var totalAmount float64
orderItems := make([]*model.OrderItem, 0, len(items))
for _, item := range items {
// Verify file exists
file, err := s.fileRepo.GetByID(ctx, item.FileID)
if err != nil || file == nil {
return nil, fmt.Errorf("file not found: %d", item.FileID)
}
// Calculate price
price, err := s.calculatePrice(ctx, item)
if err != nil {
return nil, err
}
orderItem := &model.OrderItem{
FileID: item.FileID,
PrinterID: intPtr(item.PrinterID),
Copies: item.Copies,
Color: item.Color,
Duplex: item.Duplex,
PageRange: item.PageRange,
Price: price,
}
orderItems = append(orderItems, orderItem)
totalAmount += price
}
// Create order
order := &model.Order{
OrderNo: orderNo,
UserID: userID,
Status: model.OrderStatusPending,
TotalAmount: totalAmount,
Remark: remark,
Items: orderItems,
}
// Save order with items
if err := s.orderRepo.CreateWithItems(ctx, order, orderItems); err != nil {
return nil, fmt.Errorf("failed to create order: %w", err)
}
return order, nil
}
func (s *OrderService) calculatePrice(ctx context.Context, item *CreateOrderItem) (float64, error) {
// Get price list
prices, err := s.priceRepo.GetAllActive(ctx)
if err != nil {
return 0, err
}
// Default price calculation (A4 B&W per page)
defaultPrice := 0.10
colorMultiplier := 3.0
duplexDiscount := 0.8
// Calculate based on file type and settings
// For simplicity, using default values
// In production, look up from price list based on file type
pageCount := 1 // Default, should get actual page count from file
if item.Copies <= 0 {
item.Copies = 1
}
price := float64(pageCount) * defaultPrice * float64(item.Copies)
if item.Color {
price *= colorMultiplier
}
if item.Duplex {
price *= duplexDiscount
}
return price, nil
}
func generateOrderNo() string {
now := time.Now()
return fmt.Sprintf("CP%d%s", now.UnixNano()/1000000, uuid.New().String()[:8])
}
func intPtr(i int32) *int32 {
return &i
}
func (s *OrderService) GetOrder(ctx context.Context, orderNo string) (*model.Order, error) {
return s.orderRepo.GetByOrderNo(ctx, orderNo)
}
func (s *OrderService) GetOrderList(ctx context.Context, userID int32, status int8, page, pageSize int) ([]*model.Order, int64, error) {
return s.orderRepo.GetListByUserID(ctx, userID, status, page, pageSize)
}
func (s *OrderService) UpdateOrderStatus(ctx context.Context, orderNo string, status int8) error {
return s.orderRepo.UpdateStatus(ctx, orderNo, status)
}
func (s *OrderService) CancelOrder(ctx context.Context, orderNo string) error {
order, err := s.orderRepo.GetByOrderNo(ctx, orderNo)
if err != nil || order == nil {
return errors.New("order not found")
}
if order.Status != model.OrderStatusPending {
return errors.New("only pending orders can be cancelled")
}
return s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusCancelled)
}
// PayOrder handles payment for an order
func (s *OrderService) PayOrder(ctx context.Context, orderNo string, payType string) error {
order, err := s.orderRepo.GetByOrderNo(ctx, orderNo)
if err != nil || order == nil {
return errors.New("order not found")
}
if order.Status != model.OrderStatusPending {
return errors.New("order is not pending")
}
// Update order status to paid
if err := s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusPaid); err != nil {
return err
}
// Create print jobs for each order item
for _, item := range order.Items {
if item.PrinterID != nil {
jobNo := generateJobNo()
job := &model.PrintJob{
JobNo: jobNo,
PrinterID: *item.PrinterID,
OrderItemID: item.ID,
Status: model.PrintJobStatusPending,
Progress: 0,
}
if err := s.printJobRepo.Create(ctx, job); err != nil {
// Log error but don't fail
continue
}
}
}
// Update order status to printing
return s.orderRepo.UpdateStatus(ctx, orderNo, model.OrderStatusPrinting)
}
func generateJobNo() string {
now := time.Now()
return fmt.Sprintf("PJ%d%s", now.UnixNano()/1000000, uuid.New().String()[:8])
}

View File

@@ -0,0 +1,194 @@
package service
import (
"context"
"sync"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/google/uuid"
)
type PrintService struct {
printerRepo *repository.PrinterRepository
printJobRepo *repository.PrintJobRepository
orderRepo *repository.OrderRepository
subscribers map[int32]chan *model.PrintJob
mu sync.RWMutex
}
func NewPrintService(
printerRepo *repository.PrinterRepository,
printJobRepo *repository.PrintJobRepository,
orderRepo *repository.OrderRepository,
) *PrintService {
s := &PrintService{
printerRepo: printerRepo,
printJobRepo: printJobRepo,
orderRepo: orderRepo,
subscribers: make(map[int32]chan *model.PrintJob),
}
// Start heartbeat checker
go s.checkPrinterHeartbeat()
return s
}
func (s *PrintService) GetPrinters(ctx context.Context) ([]*model.Printer, error) {
return s.printerRepo.GetAllActive(ctx)
}
func (s *PrintService) GetPrinterStatus(ctx context.Context, printerID int32) (*model.Printer, int64, error) {
printer, err := s.printerRepo.GetByID(ctx, printerID)
if err != nil || printer == nil {
return nil, 0, err
}
queueCount, err := s.printJobRepo.GetQueueCount(ctx, printerID)
if err != nil {
return nil, 0, err
}
return printer, queueCount, nil
}
func (s *PrintService) RegisterPrinter(ctx context.Context, name, displayName, apiKey string) (*model.Printer, string, error) {
// Check if printer already exists
existing, err := s.printerRepo.GetByName(ctx, name)
if err != nil {
return nil, "", err
}
if existing != nil {
return nil, "", nil
}
// Generate token for this printer
token := uuid.New().String()
printer := &model.Printer{
Name: name,
DisplayName: displayName,
APIKey: apiKey,
IsActive: true,
Status: model.PrinterStatusIdle,
}
if err := s.printerRepo.Create(ctx, printer); err != nil {
return nil, "", err
}
return printer, token, nil
}
func (s *PrintService) PrinterHeartbeat(ctx context.Context, printerID int32, status int8, token string) error {
// Verify token (in production, validate against stored token)
// For now, just update heartbeat and status
if err := s.printerRepo.UpdateHeartbeat(ctx, printerID); err != nil {
return err
}
return s.printerRepo.UpdateStatus(ctx, printerID, status)
}
func (s *PrintService) SubmitPrintJob(ctx context.Context, orderItemID int32, printerID int32, filePath string, copies int, color, duplex bool, pageRange string) (*model.PrintJob, error) {
jobNo := generateJobNo()
job := &model.PrintJob{
JobNo: jobNo,
PrinterID: printerID,
OrderItemID: orderItemID,
Status: model.PrintJobStatusPending,
Progress: 0,
}
if err := s.printJobRepo.Create(ctx, job); err != nil {
return nil, err
}
// Notify subscriber
s.mu.RLock()
ch, ok := s.subscribers[printerID]
s.mu.RUnlock()
if ok {
select {
case ch <- job:
default:
}
}
return job, nil
}
func (s *PrintService) CancelPrintJob(ctx context.Context, jobNo string) error {
return s.printJobRepo.UpdateStatus(ctx, jobNo, model.PrintJobStatusCancelled, 0, "cancelled by user")
}
func (s *PrintService) SubscribeJobs(ctx context.Context, printerID int32, token string) (<-chan *model.PrintJob, error) {
ch := make(chan *model.PrintJob, 100)
s.mu.Lock()
s.subscribers[printerID] = ch
s.mu.Unlock()
// Send pending jobs immediately
go func() {
jobs, err := s.printJobRepo.GetPendingJobs(ctx, printerID)
if err != nil {
return
}
for _, job := range jobs {
select {
case ch <- job:
default:
}
}
}()
return ch, nil
}
func (s *PrintService) UnsubscribeJobs(printerID int32) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.subscribers[printerID]; ok {
close(ch)
delete(s.subscribers, printerID)
}
}
func (s *PrintService) ReportJobStatus(ctx context.Context, jobNo string, status string, progress int, errorMsg string) error {
// Update job status
if err := s.printJobRepo.UpdateStatus(ctx, jobNo, status, progress, errorMsg); err != nil {
return err
}
// If completed, check if all jobs for the order are done
if status == model.PrintJobStatusCompleted {
job, _ := s.printJobRepo.GetByJobNo(ctx, jobNo)
if job != nil {
// Check if all jobs for the order are completed
s.checkOrderCompletion(ctx, job.OrderItemID)
}
}
return nil
}
func (s *PrintService) checkOrderCompletion(ctx context.Context, orderItemID int32) {
// In production, check if all print jobs for the order are completed
// and update order status accordingly
}
func (s *PrintService) checkPrinterHeartbeat() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
// In production, check LastHeartbeat and mark printers as offline if no heartbeat
// This is a placeholder for the heartbeat checker
}
}
// ValidatePrinterToken validates the API key and returns the printer
func (s *PrintService) ValidatePrinterToken(ctx context.Context, apiKey string) (*model.Printer, error) {
return s.printerRepo.GetByAPIKey(ctx, apiKey)
}