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,236 @@
# 云印享 - 付费打印服务系统
## 项目简介
云印享是一个基于 gRPC 的付费打印服务后端系统,支持用户管理、文件上传下载、订单处理、打印任务调度、微信支付、文库管理等核心功能。
## 技术栈
- **语言**: Go 1.21+
- **框架**: gRPC + Protobuf
- **数据库**: MySQL + GORM
- **认证**: JWT (JSON Web Token)
- **支付**: 微信支付 (gopay)
- **配置**: Viper
## 项目结构
```
cloudprint-backend/
├── cmd/server/ # 应用入口
│ └── main.go
├── config/ # 配置管理
│ └── config.go
├── internal/
│ ├── grpc/ # gRPC 服务层
│ │ ├── interceptor/ # 拦截器 (认证)
│ │ └── service/ # gRPC 服务实现
│ ├── model/ # 数据模型
│ ├── repository/ # 数据访问层
│ └── service/ # 业务逻辑层
├── pkg/ # 公共工具包
│ ├── jwt/ # JWT 认证
│ ├── pay/ # 微信支付
│ └── upload/ # 文件上传
├── proto/ # Protobuf 定义
│ ├── cloudprint.proto
│ └── generated/ # 生成的代码
├── scripts/ # 数据库脚本
│ └── db.sql
├── config.yaml # 配置文件
├── go.mod
└── go.sum
```
## 服务列表
| 服务 | 端口 | 描述 |
|------|------|------|
| gRPC Server | 8080 | 主服务端口 |
## 快速开始
### 1. 环境要求
- Go 1.21+
- MySQL 5.7+
- protoc (Protocol Buffer 编译器)
### 2. 数据库初始化
```bash
mysql -u root -p < scripts/db.sql
```
### 3. 配置修改
编辑 `config.yaml` 文件,修改以下配置:
```yaml
database:
host: "localhost"
port: 3306
username: "root"
password: "your-password"
database: "cloudprint"
jwt:
secret: "your-secret-key-change-in-production"
wechat:
app_id: "your-app-id"
app_secret: "your-app-secret"
mch_id: "your-merchant-id"
api_key: "your-api-key"
notify_url: "http://your-domain.com/pay/callback"
```
### 4. 编译生成 gRPC 代码
```bash
cd cloudprint-backend
protoc --go_out=./proto/generated --go_opt=paths=source_relative \
--go-grpc_out=./proto/generated --go-grpc_opt=paths=source_relative \
proto/cloudprint.proto
```
### 5. 运行服务
```bash
go run cmd/server/main.go -config config.yaml
```
## API 文档
### AuthService (用户认证服务)
| 方法 | 描述 |
|------|------|
| Login | 微信登录,获取 AccessToken 和 RefreshToken |
| RefreshToken | 刷新 AccessToken |
| GetUserInfo | 获取用户信息 |
| UpdateUser | 更新用户信息(昵称、头像) |
### FileService (文件管理服务)
| 方法 | 描述 |
|------|------|
| Upload | 流式上传文件(支持分片) |
| GetFile | 流式下载文件 |
| DeleteFile | 删除文件 |
| AddWatermark | 添加水印 |
| GetFileList | 获取文件列表 |
### OrderService (订单服务)
| 方法 | 描述 |
|------|------|
| CreateOrder | 创建订单 |
| GetOrder | 获取订单详情 |
| GetOrderList | 获取订单列表 |
| UpdateOrderStatus | 更新订单状态 |
| CancelOrder | 取消订单 |
### PrintService (打印服务)
| 方法 | 描述 |
|------|------|
| GetPrinters | 获取打印机列表 |
| GetPrinterStatus | 获取打印机状态 |
| RegisterPrinter | 注册打印机 |
| PrinterHeartbeat | 打印机心跳 |
| SubmitPrintJob | 提交打印任务 |
| CancelPrintJob | 取消打印任务 |
| SubscribeJobs | 订阅打印任务(流式) |
| ReportJobStatus | 上报任务状态 |
### LibraryService (文库服务)
| 方法 | 描述 |
|------|------|
| GetCategories | 获取文库分类 |
| GetFiles | 获取文库文件列表 |
| UploadFile | 上传文库文件 |
| DeleteFile | 删除文库文件 |
### PriceService (价目表服务)
| 方法 | 描述 |
|------|------|
| GetPriceList | 获取价目表 |
| CreatePriceItem | 创建价目项 |
| UpdatePriceItem | 更新价目项 |
### PayService (支付服务)
| 方法 | 描述 |
|------|------|
| CreateWxPayOrder | 创建微信支付订单 |
| QueryPayStatus | 查询支付状态 |
| HandlePayCallback | 处理支付回调 |
| PayByBalance | 余额支付 |
### AdminService (管理后台服务)
| 方法 | 描述 |
|------|------|
| Login | 管理员登录 |
| GetStatistics | 获取统计数据 |
## 数据模型
### 订单状态
| 值 | 状态 |
|----|------|
| 0 | 待支付 |
| 1 | 已支付 |
| 2 | 打印中 |
| 3 | 已完成 |
| 4 | 已取消 |
### 支付状态
| 值 | 状态 |
|----|------|
| 0 | 待支付 |
| 1 | 支付中 |
| 2 | 成功 |
| 3 | 失败 |
### 打印机状态
| 值 | 状态 |
|----|------|
| 0 | 空闲 |
| 1 | 打印中 |
| 2 | 离线 |
### 打印任务状态
| 值 | 状态 |
|----|------|
| pending | 等待中 |
| printing | 打印中 |
| completed | 已完成 |
| failed | 失败 |
| cancelled | 已取消 |
## 默认账号
- 管理员用户名: `admin`
- 管理员密码: `admin123` (需在生产环境修改)
## 初始价目表
| 名称 | 类型 | 价格 | 单位 |
|------|------|------|------|
| A4黑白打印 | A4 | 0.10元 | 页 |
| A4彩色打印 | A4 | 0.30元 | 页 |
| A3黑白打印 | A3 | 0.20元 | 页 |
| A3彩色打印 | A3 | 0.60元 | 页 |
| 照片打印 | 照片纸 | 2.00元 | 张 |
## 许可证
MIT License

View File

@@ -0,0 +1,163 @@
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"github.com/cloudprint/cloudprint-backend/config"
"github.com/cloudprint/cloudprint-backend/internal/grpc"
"github.com/cloudprint/cloudprint-backend/internal/grpc/interceptor"
"github.com/cloudprint/cloudprint-backend/internal/grpc/service"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"github.com/cloudprint/cloudprint-backend/pkg/pay"
"github.com/cloudprint/cloudprint-backend/pkg/upload"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"go.uber.org/zap"
"google.golang.org/grpc"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
// Load configuration
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize logger
zapLogger, _ := zap.NewProduction()
defer zapLogger.Sync()
// Initialize database
db, err := initDB(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Auto migrate
if err := db.AutoMigrate(
&model.User{},
&model.Admin{},
&model.Printer{},
&model.Order{},
&model.OrderItem{},
&model.File{},
&model.LibraryCategory{},
&model.LibraryFile{},
&model.PrintJob{},
&model.PriceList{},
&model.Payment{},
); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
// Initialize repositories
userRepo := repository.NewUserRepository(db)
adminRepo := repository.NewAdminRepository(db)
fileRepo := repository.NewFileRepository(db)
orderRepo := repository.NewOrderRepository(db)
orderItemRepo := repository.NewOrderItemRepository(db)
printerRepo := repository.NewPrinterRepository(db)
printJobRepo := repository.NewPrintJobRepository(db)
libraryRepo := repository.NewLibraryRepository(db)
priceRepo := repository.NewPriceRepository(db)
paymentRepo := repository.NewPaymentRepository(db)
// Initialize JWT manager
jwtManager := jwt.NewJWTManager(
cfg.JWT.Secret,
cfg.JWT.AccessTokenTTL,
cfg.JWT.RefreshTokenTTL,
)
// Initialize file uploader
fileUploader := upload.NewFileUploader(
cfg.Upload.Path,
cfg.Upload.ChunkPath,
cfg.Upload.MaxSize,
)
// Initialize WeChat pay
weChatPay := pay.NewWeChatPay(&cfg.WeChat)
// Initialize services
authService := service.NewAuthService(userRepo, adminRepo, jwtManager, nil, &cfg.WeChat)
fileService := service.NewFileService(fileRepo, fileUploader, cfg.Upload.Path)
orderService := service.NewOrderService(orderRepo, orderItemRepo, fileRepo, priceRepo, userRepo, printerRepo, printJobRepo)
printService := service.NewPrintService(printerRepo, printJobRepo, orderRepo)
// Initialize gRPC server
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(interceptor.NewAuthInterceptor(jwtManager).UnaryServerInterceptor()),
)
// Register gRPC services
pb.RegisterAuthServiceServer(grpcServer, service.NewAuthServiceServer(authService))
pb.RegisterFileServiceServer(grpcServer, service.NewFileServiceServer(fileService))
pb.RegisterOrderServiceServer(grpcServer, service.NewOrderServiceServer(orderService))
pb.RegisterPrintServiceServer(grpcServer, service.NewPrintServiceServer(printService))
pb.RegisterLibraryServiceServer(grpcServer, service.NewLibraryServiceServer(libraryRepo, fileRepo))
pb.RegisterPriceServiceServer(grpcServer, service.NewPriceServiceServer(priceRepo))
pb.RegisterPayServiceServer(grpcServer, service.NewPayServiceServer(orderService, paymentRepo, weChatPay, userRepo))
pb.RegisterAdminServiceServer(grpcServer, service.NewAdminServiceServer(authService, orderRepo, printerRepo))
// Start server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
grpcServer.GracefulStop()
}()
zapLogger.Info("Starting gRPC server", zap.String("address", addr))
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
func initDB(cfg *config.Config) (*gorm.DB, error) {
gormLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
LogLevel: logger.Info,
},
)
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
return db, nil
}

View File

@@ -0,0 +1,98 @@
package config
import (
"fmt"
"time"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Upload UploadConfig `mapstructure:"upload"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.Username, d.Password, d.Host, d.Port, d.Database)
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
func (r *RedisConfig) Addr() string {
return fmt.Sprintf("%s:%d", r.Host, r.Port)
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessTokenTTL time.Duration `mapstructure:"access_token_ttl"`
RefreshTokenTTL time.Duration `mapstructure:"refresh_token_ttl"`
}
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
MchID string `mapstructure:"mch_id"`
ApiKey string `mapstructure:"api_key"`
NotifyURL string `mapstructure:"notify_url"`
CertPath string `mapstructure:"cert_path"`
CertContent string `mapstructure:"cert_content"`
}
type UploadConfig struct {
Path string `mapstructure:"path"`
MaxSize int64 `mapstructure:"max_size"`
ChunkPath string `mapstructure:"chunk_path"`
}
var GlobalConfig *Config
func LoadConfig(path string) (*Config, error) {
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.port", 8080)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("jwt.access_token_ttl", "24h")
viper.SetDefault("jwt.refresh_token_ttl", "7d")
viper.SetDefault("upload.max_size", 52428800) // 50MB
viper.SetDefault("upload.chunk_path", "./uploads/chunks")
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
GlobalConfig = &cfg
return &cfg, nil
}

File diff suppressed because it is too large Load Diff

43
cloudprint-backend/go.mod Normal file
View File

@@ -0,0 +1,43 @@
module github.com/cloudprint/cloudprint-backend
go 1.21
require (
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/iGoogle-ink/gopay v1.5.14
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.18.0
google.golang.org/grpc v1.60.1
google.golang.org/protobuf v1.32.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

303
cloudprint-backend/go.sum Normal file
View File

@@ -0,0 +1,303 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/sketches-go v0.0.0-20190923095040-43f19ad77ff7/go.mod h1:Q5DbzQ+3AkgGwymQO7aZFNP7ns2lZKGtvRBzRXfdi60=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/benbjohnson/clock v1.0.0/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgryski/go-rendezvous v0.0.0-20200609043717-5ab96a526299/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-redis/redis/v8 v8.0.0-beta.5/go.mod h1:Mm9EH/5UMRx680UIryN6rd5XFn/L7zORPqLV+1D5thQ=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/iGoogle-ink/gopay v1.5.14 h1:vcwdqNeccx8FWrnqKTeZZSK5FcAHGUD07OFHwhMheBs=
github.com/iGoogle-ink/gopay v1.5.14/go.mod h1:JuI4rWXHSTEgFHD5C5xfpy3Do3b0pIcu/wdsRyw/Ds4=
github.com/iGoogle-ink/gotil v1.0.2 h1:z58bvmhq31twxecMkyWU5Mh8H7T9SGPOTvUuAw9vB7M=
github.com/iGoogle-ink/gotil v1.0.2/go.mod h1:HOeCIcJkJLAsQ+cAPdozZS3huVDT4YUKN/F7tmvvrbM=
github.com/jinzhu/gorm v1.9.14/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opentracing/opentracing-go v1.1.1-0.20190913142402-a7454ce5950e/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opentelemetry.io/otel v0.6.0/go.mod h1:jzBIgIzK43Iu1BpDAXwqOd6UPsSAk+ewVZ5ofSXw4Ek=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY=

View File

@@ -0,0 +1,125 @@
package interceptor
import (
"context"
"strings"
"github.com/cloudprint/cloudprint-backend/pkg/jwt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const (
AuthorizationHeader = "authorization"
BearerPrefix = "Bearer "
UserIDKey = "user_id"
UserTypeKey = "user_type"
)
type AuthInterceptor struct {
jwtManager *jwt.JWTManager
}
func NewAuthInterceptor(jwtManager *jwt.JWTManager) *AuthInterceptor {
return &AuthInterceptor{jwtManager: jwtManager}
}
// UnaryServerInterceptor returns a unary server interceptor that handles authentication
func (i *AuthInterceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Skip auth for certain methods
if isPublicMethod(info.FullMethod) {
return handler(ctx, req)
}
claims, err := i.extractAndValidateToken(ctx)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add user info to context
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserTypeKey, claims.UserType)
return handler(ctx, req)
}
}
// StreamServerInterceptor returns a streaming server interceptor that handles authentication
func (i *AuthInterceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
// Skip auth for certain methods
if isPublicMethod(info.FullMethod) {
return handler(srv, ss)
}
claims, err := i.extractAndValidateToken(ss.Context())
if err != nil {
return status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add user info to context
ctx := context.WithValue(ss.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserTypeKey, claims.UserType)
wrapped := &serverStreamWrapper{
ServerStream: ss,
ctx: ctx,
}
return handler(srv, wrapped)
}
}
func (i *AuthInterceptor) extractAndValidateToken(ctx context.Context) (*jwt.Claims, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
authHeader := md.Get(AuthorizationHeader)
if len(authHeader) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}
tokenString := strings.TrimPrefix(authHeader[0], BearerPrefix)
if tokenString == authHeader[0] {
return nil, status.Error(codes.Unauthenticated, "invalid authorization format")
}
return i.jwtManager.ValidateToken(tokenString)
}
func isPublicMethod(method string) bool {
publicMethods := map[string]bool{
"/cloudprint.AuthService/Login": true,
"/cloudprint.AuthService/RefreshToken": true,
"/cloudprint.AdminService/Login": true,
"/cloudprint.PayService/HandlePayCallback": true,
}
return publicMethods[method]
}
// serverStreamWrapper wraps grpc.ServerStream to override Context method
type serverStreamWrapper struct {
grpc.ServerStream
ctx context.Context
}
func (w *serverStreamWrapper) Context() context.Context {
return w.ctx
}
// GetUserIDFromContext extracts user ID from context
func GetUserIDFromContext(ctx context.Context) (int32, bool) {
userID, ok := ctx.Value(UserIDKey).(int32)
return userID, ok
}
// GetUserTypeFromContext extracts user type from context
func GetUserTypeFromContext(ctx context.Context) (string, bool) {
userType, ok := ctx.Value(UserTypeKey).(string)
return userType, ok
}

View File

@@ -0,0 +1,92 @@
package grpc
import (
"fmt"
"net"
"github.com/cloudprint/cloudprint-backend/internal/grpc/interceptor"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
type Server struct {
grpcServer *grpc.Server
port int
}
type ServerOption func(*Server)
func WithPort(port int) ServerOption {
return func(s *Server) {
s.port = port
}
}
func NewServer(opts ...ServerOption) *Server {
s := &Server{
port: 8080,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Server) RegisterServices(grpcServer *grpc.Server) {
// Services will be registered here
// This is a placeholder for the actual service registration
}
func (s *Server) Start() error {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
s.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(interceptor.AuthInterceptor{}.UnaryServerInterceptor()),
)
// Register reflection service for debugging
reflection.Register(s.grpcServer)
if err := s.grpcServer.Serve(lis); err != nil {
return fmt.Errorf("failed to serve: %v", err)
}
return nil
}
func (s *Server) Stop() {
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
}
// Register all gRPC services
func RegisterServices(grpcServer *grpc.Server) {
// AuthService
// pb.RegisterAuthServiceServer(grpcServer, authServiceServer)
// FileService
// pb.RegisterFileServiceServer(grpcServer, fileServiceServer)
// OrderService
// pb.RegisterOrderServiceServer(grpcServer, orderServiceServer)
// PrintService
// pb.RegisterPrintServiceServer(grpcServer, printServiceServer)
// LibraryService
// pb.RegisterLibraryServiceServer(grpcServer, libraryServiceServer)
// PriceService
// pb.RegisterPriceServiceServer(grpcServer, priceServiceServer)
// PayService
// pb.RegisterPayServiceServer(grpcServer, payServiceServer)
// AdminService
// pb.RegisterAdminServiceServer(grpcServer, adminServiceServer)
}

View File

@@ -0,0 +1,68 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type AdminServiceServer struct {
pb.UnimplementedAdminServiceServer
authService *service.AuthService
orderRepo *repository.OrderRepository
printerRepo *repository.PrinterRepository
}
func NewAdminServiceServer(
authService *service.AuthService,
orderRepo *repository.OrderRepository,
printerRepo *repository.PrinterRepository,
) *AdminServiceServer {
return &AdminServiceServer{
authService: authService,
orderRepo: orderRepo,
printerRepo: printerRepo,
}
}
func (s *AdminServiceServer) Login(ctx context.Context, req *pb.AdminLoginRequest) (*pb.AdminLoginResponse, error) {
result, err := s.authService.AdminLogin(ctx, req.Username, req.Password)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "login failed: %v", err)
}
return &pb.AdminLoginResponse{
AccessToken: result.AccessToken,
Admin: &pb.Admin{
Id: result.Admin.ID,
Username: result.Admin.Username,
Nickname: result.Admin.Nickname,
Role: int32(result.Admin.Role),
},
}, nil
}
func (s *AdminServiceServer) GetStatistics(ctx context.Context, req *pb.GetStatisticsRequest) (*pb.Statistics, error) {
totalUsers, totalOrders, todayOrders, todayRevenue, pendingOrders, err := s.orderRepo.GetStatistics(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get statistics failed: %v", err)
}
activePrinters, err := s.printerRepo.CountActive(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get printer count failed: %v", err)
}
return &pb.Statistics{
TotalUsers: totalUsers,
TotalOrders: totalOrders,
TodayOrders: todayOrders,
TodayRevenue: todayRevenue,
PendingOrders: pendingOrders,
ActivePrinters: int32(activePrinters),
}, nil
}

View File

@@ -0,0 +1,63 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type AuthServiceServer struct {
pb.UnimplementedAuthServiceServer
authService *service.AuthService
}
func NewAuthServiceServer(authService *service.AuthService) *AuthServiceServer {
return &AuthServiceServer{
authService: authService,
}
}
func (s *AuthServiceServer) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
result, err := s.authService.Login(ctx, req.Code)
if err != nil {
return nil, status.Errorf(codes.Internal, "login failed: %v", err)
}
return &pb.LoginResponse{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
User: &pb.User{
Id: result.User.ID,
Openid: result.User.Openid,
Nickname: result.User.Nickname,
Avatar: result.User.Avatar,
Balance: result.User.Balance,
},
}, nil
}
func (s *AuthServiceServer) RefreshToken(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
newToken, err := s.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return nil, status.Errorf(codes.Internal, "refresh token failed: %v", err)
}
return &pb.RefreshResponse{
AccessToken: newToken,
}, nil
}
func (s *AuthServiceServer) GetUserInfo(ctx context.Context, req *pb.GetUserInfoRequest) (*pb.User, error) {
// In a real implementation, extract user ID from context
// For now, this is a placeholder
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (s *AuthServiceServer) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) {
// In a real implementation, extract user ID from context
// For now, this is a placeholder
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}

View File

@@ -0,0 +1,141 @@
package service
import (
"context"
"io"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type FileServiceServer struct {
pb.UnimplementedFileServiceServer
fileService *service.FileService
}
func NewFileServiceServer(fileService *service.FileService) *FileServiceServer {
return &FileServiceServer{
fileService: fileService,
}
}
func (s *FileServiceServer) Upload(stream pb.FileService_UploadServer) (*pb.UploadResponse, error) {
var fileName, fileType, uploaderType string
var uploaderID int32
var totalSize int64
var chunks [][]byte
for {
req, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return nil, status.Errorf(codes.Internal, "receive chunk failed: %v", err)
}
if fileName == "" {
fileName = req.FileName
fileType = req.FileType
uploaderType = "user" // Extract from context in production
uploaderID = 1 // Extract from context in production
totalSize = req.FileSize
}
chunks = append(chunks, req.Data)
}
// Concatenate chunks
var data []byte
for _, chunk := range chunks {
data = append(data, chunk...)
}
file, err := s.fileService.Upload(stream.Context(), fileName, fileType, uploaderType, uploaderID, data)
if err != nil {
return nil, status.Errorf(codes.Internal, "upload failed: %v", err)
}
return &pb.UploadResponse{
FileId: file.ID,
FilePath: file.FilePath,
FileUrl: file.FilePath,
}, nil
}
func (s *FileServiceServer) GetFile(req *pb.GetFileRequest, stream pb.FileService_GetFileServer) error {
file, err := s.fileService.GetFile(stream.Context(), req.FileId)
if err != nil || file == nil {
return status.Errorf(codes.NotFound, "file not found")
}
reader, err := s.fileService.GetFileReader(stream.Context(), file)
if err != nil {
return status.Errorf(codes.Internal, "read file failed: %v", err)
}
defer reader.Close()
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if n > 0 {
if err := stream.Send(&pb.FileData{Data: buf[:n], IsLast: false}); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return stream.Send(&pb.FileData{IsLast: true})
}
func (s *FileServiceServer) DeleteFile(ctx context.Context, req *pb.DeleteFileRequest) (*pb.Empty, error) {
err := s.fileService.DeleteFile(ctx, req.FileId, "user", 1)
if err != nil {
return nil, status.Errorf(codes.Internal, "delete file failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *FileServiceServer) AddWatermark(ctx context.Context, req *pb.WatermarkRequest) (*pb.WatermarkResponse, error) {
file, err := s.fileService.AddWatermark(ctx, req.FileId, req.Text, req.Opacity, int(req.Position), int(req.FontSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "add watermark failed: %v", err)
}
return &pb.WatermarkResponse{
FileId: file.ID,
FilePath: file.FilePath,
}, nil
}
func (s *FileServiceServer) GetFileList(ctx context.Context, req *pb.GetFileListRequest) (*pb.FileListResponse, error) {
files, total, err := s.fileService.GetFileList(ctx, req.UploaderType, req.UploaderId, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get file list failed: %v", err)
}
pbFiles := make([]*pb.FileInfo, 0, len(files))
for _, f := range files {
pbFiles = append(pbFiles, &pb.FileInfo{
Id: f.ID,
FileName: f.FileName,
FilePath: f.FilePath,
FileType: f.FileType,
FileSize: f.FileSize,
})
}
return &pb.FileListResponse{
Files: pbFiles,
Total: int32(total),
}, nil
}

View File

@@ -0,0 +1,91 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type LibraryServiceServer struct {
pb.UnimplementedLibraryServiceServer
libraryRepo *repository.LibraryRepository
fileRepo *repository.FileRepository
}
func NewLibraryServiceServer(libraryRepo *repository.LibraryRepository, fileRepo *repository.FileRepository) *LibraryServiceServer {
return &LibraryServiceServer{
libraryRepo: libraryRepo,
fileRepo: fileRepo,
}
}
func (s *LibraryServiceServer) GetCategories(ctx context.Context, req *pb.GetCategoriesRequest) (*pb.CategoryListResponse, error) {
categories, err := s.libraryRepo.GetAllCategories(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get categories failed: %v", err)
}
pbCategories := make([]*pb.LibraryCategory, 0, len(categories))
for _, cat := range categories {
pbCategories = append(pbCategories, &pb.LibraryCategory{
Id: cat.ID,
Name: cat.Name,
Icon: cat.Icon,
SortOrder: int32(cat.SortOrder),
})
}
return &pb.CategoryListResponse{Categories: pbCategories}, nil
}
func (s *LibraryServiceServer) GetFiles(ctx context.Context, req *pb.GetLibraryFilesRequest) (*pb.LibraryFileListResponse, error) {
files, total, err := s.libraryRepo.GetFilesByCategory(ctx, req.CategoryId, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get files failed: %v", err)
}
pbFiles := make([]*pb.LibraryFile, 0, len(files))
for _, file := range files {
pbFile := &pb.LibraryFile{
Id: file.ID,
CategoryId: file.CategoryID,
FileId: file.FileID,
Title: file.Title,
Description: file.Description,
PageCount: int32(file.PageCount),
DownloadCount: int32(file.DownloadCount),
}
if file.File != nil {
pbFile.File = &pb.FileInfo{
Id: file.File.ID,
FileName: file.File.FileName,
FilePath: file.File.FilePath,
FileType: file.File.FileType,
FileSize: file.File.FileSize,
}
}
pbFiles = append(pbFiles, pbFile)
}
return &pb.LibraryFileListResponse{
Files: pbFiles,
Total: int32(total),
}, nil
}
func (s *LibraryServiceServer) UploadFile(stream pb.LibraryService_UploadFileServer) error {
// Placeholder for streaming upload
return status.Errorf(codes.Unimplemented, "streaming upload not implemented")
}
func (s *LibraryServiceServer) DeleteFile(ctx context.Context, req *pb.DeleteLibraryFileRequest) (*pb.Empty, error) {
err := s.libraryRepo.DeleteFile(ctx, req.LibraryFileId)
if err != nil {
return nil, status.Errorf(codes.Internal, "delete file failed: %v", err)
}
return &pb.Empty{}, nil
}

View File

@@ -0,0 +1,98 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type OrderServiceServer struct {
pb.UnimplementedOrderServiceServer
orderService *service.OrderService
}
func NewOrderServiceServer(orderService *service.OrderService) *OrderServiceServer {
return &OrderServiceServer{
orderService: orderService,
}
}
func (s *OrderServiceServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
// Extract user ID from context (set by auth interceptor)
userID := int32(1) // Placeholder
items := make([]*service.CreateOrderItem, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, &service.CreateOrderItem{
FileID: item.FileId,
PrinterID: item.PrinterId,
Copies: int(item.Copies),
Color: item.Color,
Duplex: item.Duplex,
PageRange: item.PageRange,
})
}
order, err := s.orderService.CreateOrder(ctx, userID, items, req.Remark)
if err != nil {
return nil, status.Errorf(codes.Internal, "create order failed: %v", err)
}
return &pb.CreateOrderResponse{
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
}, nil
}
func (s *OrderServiceServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.orderService.GetOrder(ctx, req.OrderNo)
if err != nil || order == nil {
return nil, status.Errorf(codes.NotFound, "order not found")
}
return convertOrderToPB(order), nil
}
func (s *OrderServiceServer) GetOrderList(ctx context.Context, req *pb.GetOrderListRequest) (*pb.OrderListResponse, error) {
orders, total, err := s.orderService.GetOrderList(ctx, req.UserId, req.Status, int(req.Page), int(req.PageSize))
if err != nil {
return nil, status.Errorf(codes.Internal, "get order list failed: %v", err)
}
pbOrders := make([]*pb.Order, 0, len(orders))
for _, order := range orders {
pbOrders = append(pbOrders, convertOrderToPB(order))
}
return &pb.OrderListResponse{
Orders: pbOrders,
Total: int32(total),
}, nil
}
func (s *OrderServiceServer) UpdateOrderStatus(ctx context.Context, req *pb.UpdateOrderStatusRequest) (*pb.Empty, error) {
err := s.orderService.UpdateOrderStatus(ctx, req.OrderNo, int8(req.Status))
if err != nil {
return nil, status.Errorf(codes.Internal, "update order status failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *OrderServiceServer) CancelOrder(ctx context.Context, req *pb.CancelOrderRequest) (*pb.Empty, error) {
err := s.orderService.CancelOrder(ctx, req.OrderNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "cancel order failed: %v", err)
}
return &pb.Empty{}, nil
}
func convertOrderToPB(order *service.Order) *pb.Order {
// Note: This is a simplified conversion. In production, you would need to handle all fields properly.
// The service.Order type here is the model, not the protobuf type.
return nil // Placeholder
}

View File

@@ -0,0 +1,201 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/model"
"github.com/cloudprint/cloudprint-backend/internal/repository"
"github.com/cloudprint/cloudprint-backend/internal/service"
"github.com/cloudprint/cloudprint-backend/pkg/pay"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PayServiceServer struct {
pb.UnimplementedPayServiceServer
orderService *service.OrderService
paymentRepo *repository.PaymentRepository
weChatPay *pay.WeChatPay
userRepo *repository.UserRepository
}
func NewPayServiceServer(
orderService *service.OrderService,
paymentRepo *repository.PaymentRepository,
weChatPay *pay.WeChatPay,
userRepo *repository.UserRepository,
) *PayServiceServer {
return &PayServiceServer{
orderService: orderService,
paymentRepo: paymentRepo,
weChatPay: weChatPay,
userRepo: userRepo,
}
}
func (s *PayServiceServer) CreateWxPayOrder(ctx context.Context, req *pb.WxPayRequest) (*pb.WxPayResponse, error) {
resp, err := s.weChatPay.UnifiedOrder(ctx, req.OrderNo, req.Amount, req.Description, req.Openid, req.ClientIp)
if err != nil {
return nil, status.Errorf(codes.Internal, "create pay order failed: %v", err)
}
if resp.ReturnCode != "SUCCESS" {
return nil, status.Errorf(codes.Internal, "wechat pay error: %s - %s", resp.ErrCode, resp.ErrMsg)
}
// Create payment record
payment := &model.Payment{
OrderNo: req.OrderNo,
PayType: "wechat",
PayStatus: model.PayStatusPaying,
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
return nil, status.Errorf(codes.Internal, "create payment record failed: %v", err)
}
return &pb.WxPayResponse{
PrepayId: resp.PrepayID,
CodeUrl: resp.CodeURL,
MwebUrl: resp.MWebURL,
}, nil
}
func (s *PayServiceServer) QueryPayStatus(ctx context.Context, req *pb.QueryPayStatusRequest) (*pb.PayStatusResponse, error) {
tradeState, _, err := s.weChatPay.QueryOrder(ctx, req.OrderNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "query pay status failed: %v", err)
}
var payStatus int32
switch tradeState {
case "SUCCESS":
payStatus = int32(model.PayStatusSuccess)
case "USERPAYING", "PAYING":
payStatus = int32(model.PayStatusPaying)
case "CLOSED", "PAYERROR", "REFUND":
payStatus = int32(model.PayStatusFailed)
default:
payStatus = int32(model.PayStatusPending)
}
return &pb.PayStatusResponse{
Status: payStatus,
}, nil
}
func (s *PayServiceServer) HandlePayCallback(ctx context.Context, req *pb.PayCallbackRequest) (*pb.PayCallbackResponse, error) {
callback, err := s.weChatPay.ParseCallback([]byte(req.Body))
if err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "parse error",
}, nil
}
if !s.weChatPay.ValidateCallback(callback) {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "validate error",
}, nil
}
if callback.ReturnCode == "SUCCESS" && callback.ResultCode == "SUCCESS" {
// Check if payment already processed (idempotency)
payment, _ := s.paymentRepo.GetByOrderNo(ctx, callback.OutTradeNo)
if payment != nil && payment.PayStatus == model.PayStatusSuccess {
// Already processed, return success
return &pb.PayCallbackResponse{
ReturnCode: "SUCCESS",
ReturnMsg: "OK",
}, nil
}
// Update payment status
if err := s.paymentRepo.UpdateStatus(ctx, callback.OutTradeNo, model.PayStatusSuccess, callback.TransactionID); err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "update payment failed",
}, nil
}
// Update order status - pay order triggers print job creation
if err := s.orderService.PayOrder(ctx, callback.OutTradeNo, "wechat"); err != nil {
return &pb.PayCallbackResponse{
ReturnCode: "FAIL",
ReturnMsg: "pay order failed",
}, nil
}
}
return &pb.PayCallbackResponse{
ReturnCode: "SUCCESS",
ReturnMsg: "OK",
}, nil
}
func (s *PayServiceServer) PayByBalance(ctx context.Context, req *pb.PayByBalanceRequest) (*pb.StatusResponse, error) {
// Get order
order, err := s.orderService.GetOrder(ctx, req.OrderNo)
if err != nil || order == nil {
return &pb.StatusResponse{
Code: 1,
Message: "order not found",
}, nil
}
// Check if order is pending
if order.Status != model.OrderStatusPending {
return &pb.StatusResponse{
Code: 1,
Message: "order is not pending",
}, nil
}
// Get user
user, err := s.userRepo.GetByID(ctx, req.UserId)
if err != nil || user == nil {
return &pb.StatusResponse{
Code: 1,
Message: "user not found",
}, nil
}
// Check balance
if user.Balance < order.TotalAmount {
return &pb.StatusResponse{
Code: 1,
Message: "insufficient balance",
}, nil
}
// Deduct balance
if err := s.userRepo.DeductBalance(ctx, req.UserId, order.TotalAmount); err != nil {
return nil, status.Errorf(codes.Internal, "deduct balance failed: %v", err)
}
// Create payment record
payment := &model.Payment{
OrderNo: order.OrderNo,
PayType: "balance",
PayStatus: model.PayStatusSuccess,
TransactionID: "balance_" + order.OrderNo,
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
// Rollback balance deduction on failure
s.userRepo.AddBalance(ctx, req.UserId, order.TotalAmount)
return nil, status.Errorf(codes.Internal, "create payment record failed: %v", err)
}
// Pay order - this creates print jobs
if err := s.orderService.PayOrder(ctx, req.OrderNo, "balance"); err != nil {
// Rollback
s.userRepo.AddBalance(ctx, req.UserId, order.TotalAmount)
return nil, status.Errorf(codes.Internal, "pay order failed: %v", err)
}
return &pb.StatusResponse{
Code: 0,
Message: "success",
}, nil
}

View File

@@ -0,0 +1,96 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/repository"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PriceServiceServer struct {
pb.UnimplementedPriceServiceServer
priceRepo *repository.PriceRepository
}
func NewPriceServiceServer(priceRepo *repository.PriceRepository) *PriceServiceServer {
return &PriceServiceServer{
priceRepo: priceRepo,
}
}
func (s *PriceServiceServer) GetPriceList(ctx context.Context, req *pb.GetPriceListRequest) (*pb.PriceListResponse, error) {
items, err := s.priceRepo.GetAllActive(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get price list failed: %v", err)
}
pbItems := make([]*pb.PriceItem, 0, len(items))
for _, item := range items {
pbItems = append(pbItems, &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
})
}
return &pb.PriceListResponse{Items: pbItems}, nil
}
func (s *PriceServiceServer) CreatePriceItem(ctx context.Context, req *pb.CreatePriceItemRequest) (*pb.PriceItem, error) {
item := &model.PriceList{
Name: req.Name,
Type: req.Type,
Price: req.Price,
Unit: req.Unit,
ColorEnabled: req.ColorEnabled,
IsActive: true,
}
if err := s.priceRepo.Create(ctx, item); err != nil {
return nil, status.Errorf(codes.Internal, "create price item failed: %v", err)
}
return &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
}, nil
}
func (s *PriceServiceServer) UpdatePriceItem(ctx context.Context, req *pb.UpdatePriceItemRequest) (*pb.PriceItem, error) {
item, err := s.priceRepo.GetByID(ctx, req.Id)
if err != nil || item == nil {
return nil, status.Errorf(codes.NotFound, "price item not found")
}
item.Name = req.Name
item.Type = req.Type
item.Price = req.Price
item.Unit = req.Unit
item.ColorEnabled = req.ColorEnabled
item.IsActive = req.IsActive
if err := s.priceRepo.Update(ctx, item); err != nil {
return nil, status.Errorf(codes.Internal, "update price item failed: %v", err)
}
return &pb.PriceItem{
Id: item.ID,
Name: item.Name,
Type: item.Type,
Price: item.Price,
Unit: item.Unit,
ColorEnabled: item.ColorEnabled,
IsActive: item.IsActive,
}, nil
}

View File

@@ -0,0 +1,136 @@
package service
import (
"context"
"github.com/cloudprint/cloudprint-backend/internal/service"
pb "github.com/cloudprint/cloudprint-backend/proto/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type PrintServiceServer struct {
pb.UnimplementedPrintServiceServer
printService *service.PrintService
}
func NewPrintServiceServer(printService *service.PrintService) *PrintServiceServer {
return &PrintServiceServer{
printService: printService,
}
}
func (s *PrintServiceServer) GetPrinters(ctx context.Context, req *pb.GetPrintersRequest) (*pb.PrinterListResponse, error) {
printers, err := s.printService.GetPrinters(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "get printers failed: %v", err)
}
pbPrinters := make([]*pb.Printer, 0, len(printers))
for _, printer := range printers {
pbPrinters = append(pbPrinters, &pb.Printer{
Id: printer.ID,
Name: printer.Name,
DisplayName: printer.DisplayName,
IsActive: printer.IsActive,
Status: int32(printer.Status),
})
}
return &pb.PrinterListResponse{Printers: pbPrinters}, nil
}
func (s *PrintServiceServer) GetPrinterStatus(ctx context.Context, req *pb.GetPrinterStatusRequest) (*pb.PrinterStatusResponse, error) {
printer, queueCount, err := s.printService.GetPrinterStatus(ctx, req.PrinterId)
if err != nil || printer == nil {
return nil, status.Errorf(codes.NotFound, "printer not found")
}
return &pb.PrinterStatusResponse{
Printer: &pb.Printer{
Id: printer.ID,
Name: printer.Name,
DisplayName: printer.DisplayName,
IsActive: printer.IsActive,
Status: int32(printer.Status),
},
QueueCount: int32(queueCount),
}, nil
}
func (s *PrintServiceServer) RegisterPrinter(ctx context.Context, req *pb.RegisterPrinterRequest) (*pb.RegisterPrinterResponse, error) {
printer, token, err := s.printService.RegisterPrinter(ctx, req.Name, req.DisplayName, req.ApiKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "register printer failed: %v", err)
}
if printer == nil {
return nil, status.Errorf(codes.AlreadyExists, "printer already exists")
}
return &pb.RegisterPrinterResponse{
PrinterId: printer.ID,
Token: token,
}, nil
}
func (s *PrintServiceServer) PrinterHeartbeat(ctx context.Context, req *pb.PrinterHeartbeatRequest) (*pb.Empty, error) {
err := s.printService.PrinterHeartbeat(ctx, req.PrinterId, int8(req.Status), req.Token)
if err != nil {
return nil, status.Errorf(codes.Internal, "heartbeat failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *PrintServiceServer) SubmitPrintJob(ctx context.Context, req *pb.SubmitPrintJobRequest) (*pb.SubmitPrintJobResponse, error) {
job, err := s.printService.SubmitPrintJob(ctx, req.OrderItemId, req.PrinterId, "", int(req.Copies), req.Color, req.Duplex, req.PageRange)
if err != nil {
return nil, status.Errorf(codes.Internal, "submit print job failed: %v", err)
}
return &pb.SubmitPrintJobResponse{
JobNo: job.JobNo,
}, nil
}
func (s *PrintServiceServer) CancelPrintJob(ctx context.Context, req *pb.CancelPrintJobRequest) (*pb.Empty, error) {
err := s.printService.CancelPrintJob(ctx, req.JobNo)
if err != nil {
return nil, status.Errorf(codes.Internal, "cancel print job failed: %v", err)
}
return &pb.Empty{}, nil
}
func (s *PrintServiceServer) SubscribeJobs(req *pb.SubscribeJobsRequest, stream pb.PrintService_SubscribeJobsServer) error {
ch, err := s.printService.SubscribeJobs(stream.Context(), req.PrinterId, req.Token)
if err != nil {
return status.Errorf(codes.Internal, "subscribe jobs failed: %v", err)
}
for job := range ch {
if err := stream.Send(&pb.PrintJob{
Id: job.ID,
JobNo: job.JobNo,
PrinterId: job.PrinterID,
OrderItemId: job.OrderItemID,
Status: job.Status,
Progress: int32(job.Progress),
ErrorMsg: job.ErrorMsg,
}); err != nil {
s.printService.UnsubscribeJobs(req.PrinterId)
return err
}
}
return nil
}
func (s *PrintServiceServer) ReportJobStatus(ctx context.Context, req *pb.ReportJobStatusRequest) (*pb.Empty, error) {
err := s.printService.ReportJobStatus(ctx, req.JobNo, req.Status, int(req.Progress), req.ErrorMsg)
if err != nil {
return nil, status.Errorf(codes.Internal, "report job status failed: %v", err)
}
return &pb.Empty{}, nil
}

View File

@@ -0,0 +1,224 @@
package model
import (
"time"
)
// User 用户表
type User struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Openid string `gorm:"uniqueIndex;size:64;not null" json:"openid"` // 微信OpenID
Nickname string `gorm:"size:128;default:''" json:"nickname"`
Avatar string `gorm:"size:512;default:''" json:"avatar"`
Balance float64 `gorm:"type:decimal(10,2);default:0.00" json:"balance"` // 余额
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Orders []Order `gorm:"foreignKey:UserID" json:"orders,omitempty"`
}
func (User) TableName() string {
return "user"
}
// Admin 管理员表
type Admin struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
PasswordHash string `gorm:"size:256;not null" json:"-"`
Nickname string `gorm:"size:128;default:''" json:"nickname"`
Role int8 `gorm:"default:1" json:"role"` // 1:普通管理员 2:超级管理员
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Admin) TableName() string {
return "admin"
}
// Printer 打印机表
type Printer struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"` // 系统打印机名
DisplayName string `gorm:"size:128;not null" json:"display_name"` // 显示名称
APIKey string `gorm:"uniqueIndex;size:64;not null" json:"api_key"` // 客户端认证Key
IsActive bool `gorm:"default:true" json:"is_active"`
Status int8 `gorm:"default:0" json:"status"` // 0:空闲 1:打印中 2:离线
LastHeartbeat *time.Time `json:"last_heartbeat"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
PrintJobs []PrintJob `gorm:"foreignKey:PrinterID" json:"print_jobs,omitempty"`
}
func (Printer) TableName() string {
return "printer"
}
// Order 订单表
type Order struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32;not null" json:"order_no"` // 订单号
UserID int32 `gorm:"not null;index" json:"user_id"`
Status int8 `gorm:"default:0" json:"status"` // 0:待支付 1:已支付 2:打印中 3:已完成 4:已取消
TotalAmount float64 `gorm:"type:decimal(10,2);not null" json:"total_amount"`
Remark string `gorm:"size:512;default:''" json:"remark"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
PaidAt *time.Time `json:"paid_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
Payment *Payment `gorm:"foreignKey:OrderNo;references:OrderNo" json:"payment,omitempty"`
}
func (Order) TableName() string {
return "order"
}
// OrderItem 订单明细表
type OrderItem struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderID int32 `gorm:"not null;index" json:"order_id"`
FileID int32 `gorm:"not null" json:"file_id"`
PrinterID *int32 `json:"printer_id"`
Copies int `gorm:"default:1" json:"copies"`
Color bool `gorm:"default:false" json:"color"`
Duplex bool `gorm:"default:false" json:"duplex"`
PageRange string `gorm:"size:64;default:'all'" json:"page_range"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
File *File `gorm:"foreignKey:FileID" json:"file,omitempty"`
Printer *Printer `gorm:"foreignKey:PrinterID" json:"printer,omitempty"`
PrintJobs []PrintJob `gorm:"foreignKey:OrderItemID" json:"print_jobs,omitempty"`
}
func (OrderItem) TableName() string {
return "order_item"
}
// File 文件表
type File struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
FileName string `gorm:"size:256;not null" json:"file_name"`
FilePath string `gorm:"size:512;not null" json:"file_path"`
FileType string `gorm:"size:32;not null" json:"file_type"` // pdf/doc/docx/xls/xlsx/ppt/pptx/txt/jpg/png/zip
FileSize int64 `gorm:"not null" json:"file_size"` // 字节
UploaderType string `gorm:"size:16;not null" json:"uploader_type"` // user/admin/system
UploaderID int32 `gorm:"not null" json:"uploader_id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (File) TableName() string {
return "file"
}
// LibraryCategory 文库分类表
type LibraryCategory struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Icon string `gorm:"size:128;default:''" json:"icon"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Files []LibraryFile `gorm:"foreignKey:CategoryID" json:"files,omitempty"`
}
func (LibraryCategory) TableName() string {
return "library_category"
}
// LibraryFile 文库文件表
type LibraryFile struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
CategoryID int32 `gorm:"not null;index" json:"category_id"`
FileID int32 `gorm:"not null" json:"file_id"`
Title string `gorm:"size:256;not null" json:"title"`
Description string `gorm:"size:512;default:''" json:"description"`
PageCount int `gorm:"default:0" json:"page_count"`
DownloadCount int `gorm:"default:0" json:"download_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Category *LibraryCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
File *File `gorm:"foreignKey:FileID" json:"file,omitempty"`
}
func (LibraryFile) TableName() string {
return "library_file"
}
// PrintJob 打印任务表
type PrintJob struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
JobNo string `gorm:"uniqueIndex;size:32;not null" json:"job_no"`
PrinterID int32 `gorm:"not null;index" json:"printer_id"`
OrderItemID int32 `gorm:"not null;index" json:"order_item_id"`
Status string `gorm:"size:16;default:'pending'" json:"status"` // pending/printing/completed/failed/cancelled
Progress int `gorm:"default:0" json:"progress"`
ErrorMsg string `gorm:"size:512;default:''" json:"error_msg"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
Printer *Printer `gorm:"foreignKey:PrinterID" json:"printer,omitempty"`
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
}
func (PrintJob) TableName() string {
return "print_job"
}
// PriceList 价目表
type PriceList struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` // 项目名称
Type string `gorm:"size:32;not null" json:"type"` // 纸张类型: A4/A3/照片纸
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 单价
Unit string `gorm:"size:16;not null" json:"unit"` // 单位: 页/张/份
ColorEnabled bool `gorm:"default:true" json:"color_enabled"` // 是否支持彩色
IsActive bool `gorm:"default:true" json:"is_active"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (PriceList) TableName() string {
return "price_list"
}
// Payment 支付记录表
type Payment struct {
ID int32 `gorm:"primaryKey;autoIncrement" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32;not null" json:"order_no"`
PayType string `gorm:"size:16;default:'wechat'" json:"pay_type"` // wechat/balance
PayStatus int8 `gorm:"default:0" json:"pay_status"` // 0:待支付 1:支付中 2:成功 3:失败
TransactionID string `gorm:"size:64;default:''" json:"transaction_id"`
PaidAt *time.Time `json:"paid_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Payment) TableName() string {
return "payment"
}
// OrderStatus 订单状态常量
const (
OrderStatusPending = 0 // 待支付
OrderStatusPaid = 1 // 已支付
OrderStatusPrinting = 2 // 打印中
OrderStatusCompleted = 3 // 已完成
OrderStatusCancelled = 4 // 已取消
)
// PayStatus 支付状态常量
const (
PayStatusPending = 0 // 待支付
PayStatusPaying = 1 // 支付中
PayStatusSuccess = 2 // 成功
PayStatusFailed = 3 // 失败
)
// PrinterStatus 打印机状态常量
const (
PrinterStatusIdle = 0 // 空闲
PrinterStatusPrinting = 1 // 打印中
PrinterStatusOffline = 2 // 离线
)
// PrintJobStatus 打印任务状态常量
const (
PrintJobStatusPending = "pending"
PrintJobStatusPrinting = "printing"
PrintJobStatusCompleted = "completed"
PrintJobStatusFailed = "failed"
PrintJobStatusCancelled = "cancelled"
)

View File

@@ -0,0 +1,49 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type AdminRepository struct {
db *gorm.DB
}
func NewAdminRepository(db *gorm.DB) *AdminRepository {
return &AdminRepository{db: db}
}
func (r *AdminRepository) Create(ctx context.Context, admin *model.Admin) error {
return r.db.WithContext(ctx).Create(admin).Error
}
func (r *AdminRepository) GetByID(ctx context.Context, id int32) (*model.Admin, error) {
var admin model.Admin
err := r.db.WithContext(ctx).First(&admin, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &admin, nil
}
func (r *AdminRepository) GetByUsername(ctx context.Context, username string) (*model.Admin, error) {
var admin model.Admin
err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &admin, nil
}
func (r *AdminRepository) Update(ctx context.Context, admin *model.Admin) error {
return r.db.WithContext(ctx).Save(admin).Error
}

View File

@@ -0,0 +1,67 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type FileRepository struct {
db *gorm.DB
}
func NewFileRepository(db *gorm.DB) *FileRepository {
return &FileRepository{db: db}
}
func (r *FileRepository) Create(ctx context.Context, file *model.File) error {
return r.db.WithContext(ctx).Create(file).Error
}
func (r *FileRepository) GetByID(ctx context.Context, id int32) (*model.File, error) {
var file model.File
err := r.db.WithContext(ctx).First(&file, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &file, nil
}
func (r *FileRepository) Delete(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.File{}, id).Error
}
func (r *FileRepository) GetListByUploader(ctx context.Context, uploaderType string, uploaderID int32, page, pageSize int) ([]*model.File, int64, error) {
var files []*model.File
var total int64
query := r.db.WithContext(ctx).Model(&model.File{})
if uploaderType != "" {
query = query.Where("uploader_type = ?", uploaderType)
}
if uploaderID > 0 {
query = query.Where("uploader_id = ?", uploaderID)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&files).Error
if err != nil {
return nil, 0, err
}
return files, total, nil
}
func (r *FileRepository) Update(ctx context.Context, file *model.File) error {
return r.db.WithContext(ctx).Save(file).Error
}

View File

@@ -0,0 +1,108 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type LibraryRepository struct {
db *gorm.DB
}
func NewLibraryRepository(db *gorm.DB) *LibraryRepository {
return &LibraryRepository{db: db}
}
// Category operations
func (r *LibraryRepository) CreateCategory(ctx context.Context, category *model.LibraryCategory) error {
return r.db.WithContext(ctx).Create(category).Error
}
func (r *LibraryRepository) GetCategoryByID(ctx context.Context, id int32) (*model.LibraryCategory, error) {
var category model.LibraryCategory
err := r.db.WithContext(ctx).First(&category, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &category, nil
}
func (r *LibraryRepository) GetAllCategories(ctx context.Context) ([]*model.LibraryCategory, error) {
var categories []*model.LibraryCategory
err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&categories).Error
return categories, err
}
func (r *LibraryRepository) UpdateCategory(ctx context.Context, category *model.LibraryCategory) error {
return r.db.WithContext(ctx).Save(category).Error
}
func (r *LibraryRepository) DeleteCategory(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.LibraryCategory{}, id).Error
}
// LibraryFile operations
func (r *LibraryRepository) CreateFile(ctx context.Context, file *model.LibraryFile) error {
return r.db.WithContext(ctx).Create(file).Error
}
func (r *LibraryRepository) GetFileByID(ctx context.Context, id int32) (*model.LibraryFile, error) {
var file model.LibraryFile
err := r.db.WithContext(ctx).Preload("File").First(&file, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &file, nil
}
func (r *LibraryRepository) GetFilesByCategory(ctx context.Context, categoryID int32, page, pageSize int) ([]*model.LibraryFile, int64, error) {
var files []*model.LibraryFile
var total int64
query := r.db.WithContext(ctx).Model(&model.LibraryFile{}).Where("category_id = ?", categoryID)
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("File").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&files).Error
if err != nil {
return nil, 0, err
}
return files, total, nil
}
func (r *LibraryRepository) DeleteFile(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Get file to delete underlying file record
var libFile model.LibraryFile
if err := tx.First(&libFile, id).Error; err != nil {
return err
}
// Delete library file
if err := tx.Delete(&libFile).Error; err != nil {
return err
}
// Delete underlying file
if err := tx.Delete(&model.File{}, libFile.FileID).Error; err != nil {
return err
}
return nil
})
}
func (r *LibraryRepository) IncrementDownloadCount(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Model(&model.LibraryFile{}).Where("id = ?", id).
Update("download_count", gorm.Expr("download_count + 1")).Error
}

View File

@@ -0,0 +1,162 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type OrderRepository struct {
db *gorm.DB
}
func NewOrderRepository(db *gorm.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) Create(ctx context.Context, order *model.Order) error {
return r.db.WithContext(ctx).Create(order).Error
}
func (r *OrderRepository) CreateWithItems(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
for _, item := range items {
item.OrderID = order.ID
if err := tx.Create(item).Error; err != nil {
return err
}
}
return nil
})
}
func (r *OrderRepository) GetByID(ctx context.Context, id int32) (*model.Order, error) {
var order model.Order
err := r.db.WithContext(ctx).Preload("Items").Preload("Items.File").Preload("Payment").
First(&order, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *OrderRepository) GetByOrderNo(ctx context.Context, orderNo string) (*model.Order, error) {
var order model.Order
err := r.db.WithContext(ctx).Preload("Items").Preload("Items.File").Preload("Items.Printer").Preload("Payment").
Where("order_no = ?", orderNo).First(&order).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *OrderRepository) GetListByUserID(ctx context.Context, userID int32, status int8, page, pageSize int) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := r.db.WithContext(ctx).Model(&model.Order{}).Where("user_id = ?", userID)
if status >= 0 {
query = query.Where("status = ?", status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("Items").Preload("Items.File").
Offset(offset).Limit(pageSize).Order("created_at DESC").
Find(&orders).Error
if err != nil {
return nil, 0, err
}
return orders, total, nil
}
func (r *OrderRepository) UpdateStatus(ctx context.Context, orderNo string, status int8) error {
updates := map[string]interface{}{
"status": status,
}
if status == model.OrderStatusPaid {
now := time.Now()
updates["paid_at"] = &now
}
return r.db.WithContext(ctx).Model(&model.Order{}).
Where("order_no = ?", orderNo).Updates(updates).Error
}
func (r *OrderRepository) GetStatistics(ctx context.Context) (totalUsers, totalOrders, todayOrders int32, todayRevenue float64, pendingOrders int32, err error) {
// 总用户数
if err = r.db.WithContext(ctx).Model(&model.User{}).Count(&totalUsers).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count users failed: %w", err)
}
// 总订单数
if err = r.db.WithContext(ctx).Model(&model.Order{}).Count(&totalOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count orders failed: %w", err)
}
// 待处理订单数
if err = r.db.WithContext(ctx).Model(&model.Order{}).Where("status IN ?", []int8{0, 1, 2}).Count(&pendingOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count pending orders failed: %w", err)
}
// 今日订单数
today := time.Now().Format("2006-01-02")
if err = r.db.WithContext(ctx).Model(&model.Order{}).
Where("DATE(created_at) = ? AND status = ?", today, model.OrderStatusPaid).
Count(&todayOrders).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("count today orders failed: %w", err)
}
// 今日收入
if err = r.db.WithContext(ctx).Model(&model.Order{}).
Select("COALESCE(SUM(total_amount), 0)").
Where("DATE(created_at) = ? AND status = ?", today, model.OrderStatusPaid).
Scan(&todayRevenue).Error; err != nil {
return 0, 0, 0, 0, 0, fmt.Errorf("sum today revenue failed: %w", err)
}
return
}
type OrderItemRepository struct {
db *gorm.DB
}
func NewOrderItemRepository(db *gorm.DB) *OrderItemRepository {
return &OrderItemRepository{db: db}
}
func (r *OrderItemRepository) GetByID(ctx context.Context, id int32) (*model.OrderItem, error) {
var item model.OrderItem
err := r.db.WithContext(ctx).Preload("File").First(&item, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *OrderItemRepository) Create(ctx context.Context, item *model.OrderItem) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *OrderItemRepository) GetByOrderID(ctx context.Context, orderID int32) ([]*model.OrderItem, error) {
var items []*model.OrderItem
err := r.db.WithContext(ctx).Where("order_id = ?", orderID).Find(&items).Error
return items, err
}

View File

@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type PriceRepository struct {
db *gorm.DB
}
func NewPriceRepository(db *gorm.DB) *PriceRepository {
return &PriceRepository{db: db}
}
func (r *PriceRepository) Create(ctx context.Context, item *model.PriceList) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *PriceRepository) GetByID(ctx context.Context, id int32) (*model.PriceList, error) {
var item model.PriceList
err := r.db.WithContext(ctx).First(&item, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *PriceRepository) GetAllActive(ctx context.Context) ([]*model.PriceList, error) {
var items []*model.PriceList
err := r.db.WithContext(ctx).Where("is_active = ?", true).Order("sort_order ASC, id ASC").Find(&items).Error
return items, err
}
func (r *PriceRepository) Update(ctx context.Context, item *model.PriceList) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *PriceRepository) Delete(ctx context.Context, id int32) error {
return r.db.WithContext(ctx).Delete(&model.PriceList{}, id).Error
}
type PaymentRepository struct {
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) *PaymentRepository {
return &PaymentRepository{db: db}
}
func (r *PaymentRepository) Create(ctx context.Context, payment *model.Payment) error {
return r.db.WithContext(ctx).Create(payment).Error
}
func (r *PaymentRepository) GetByOrderNo(ctx context.Context, orderNo string) (*model.Payment, error) {
var payment model.Payment
err := r.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&payment).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &payment, nil
}
func (r *PaymentRepository) UpdateStatus(ctx context.Context, orderNo string, status int8, transactionID string) error {
updates := map[string]interface{}{
"pay_status": status,
"transaction_id": transactionID,
}
return r.db.WithContext(ctx).Model(&model.Payment{}).
Where("order_no = ?", orderNo).Updates(updates).Error
}

View File

@@ -0,0 +1,150 @@
package repository
import (
"context"
"errors"
"time"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type PrinterRepository struct {
db *gorm.DB
}
func NewPrinterRepository(db *gorm.DB) *PrinterRepository {
return &PrinterRepository{db: db}
}
func (r *PrinterRepository) Create(ctx context.Context, printer *model.Printer) error {
return r.db.WithContext(ctx).Create(printer).Error
}
func (r *PrinterRepository) GetByID(ctx context.Context, id int32) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).First(&printer, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetByName(ctx context.Context, name string) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).Where("name = ?", name).First(&printer).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetByAPIKey(ctx context.Context, apiKey string) (*model.Printer, error) {
var printer model.Printer
err := r.db.WithContext(ctx).Where("api_key = ?", apiKey).First(&printer).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &printer, nil
}
func (r *PrinterRepository) GetAllActive(ctx context.Context) ([]*model.Printer, error) {
var printers []*model.Printer
err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&printers).Error
return printers, err
}
func (r *PrinterRepository) UpdateStatus(ctx context.Context, id int32, status int8) error {
return r.db.WithContext(ctx).Model(&model.Printer{}).Where("id = ?", id).Update("status", status).Error
}
func (r *PrinterRepository) UpdateHeartbeat(ctx context.Context, id int32) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.Printer{}).Where("id = ?", id).Update("last_heartbeat", &now).Error
}
func (r *PrinterRepository) CountActive(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Printer{}).Where("is_active = ? AND status != ?", true, model.PrinterStatusOffline).Count(&count).Error
return count, err
}
type PrintJobRepository struct {
db *gorm.DB
}
func NewPrintJobRepository(db *gorm.DB) *PrintJobRepository {
return &PrintJobRepository{db: db}
}
func (r *PrintJobRepository) Create(ctx context.Context, job *model.PrintJob) error {
return r.db.WithContext(ctx).Create(job).Error
}
func (r *PrintJobRepository) GetByID(ctx context.Context, id int32) (*model.PrintJob, error) {
var job model.PrintJob
err := r.db.WithContext(ctx).First(&job, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (r *PrintJobRepository) GetByJobNo(ctx context.Context, jobNo string) (*model.PrintJob, error) {
var job model.PrintJob
err := r.db.WithContext(ctx).Where("job_no = ?", jobNo).First(&job).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (r *PrintJobRepository) GetPendingJobs(ctx context.Context, printerID int32) ([]*model.PrintJob, error) {
var jobs []*model.PrintJob
err := r.db.WithContext(ctx).
Where("printer_id = ? AND status = ?", printerID, model.PrintJobStatusPending).
Preload("OrderItem").
Preload("OrderItem.File").
Find(&jobs).Error
return jobs, err
}
func (r *PrintJobRepository) UpdateStatus(ctx context.Context, jobNo string, status string, progress int, errorMsg string) error {
updates := map[string]interface{}{
"status": status,
"progress": progress,
"error_msg": errorMsg,
}
if status == model.PrintJobStatusPrinting {
now := time.Now()
updates["started_at"] = &now
} else if status == model.PrintJobStatusCompleted || status == model.PrintJobStatusFailed {
now := time.Now()
updates["completed_at"] = &now
}
return r.db.WithContext(ctx).Model(&model.PrintJob{}).
Where("job_no = ?", jobNo).Updates(updates).Error
}
func (r *PrintJobRepository) GetQueueCount(ctx context.Context, printerID int32) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.PrintJob{}).
Where("printer_id = ? AND status IN ?", printerID, []string{model.PrintJobStatusPending, model.PrintJobStatusPrinting}).
Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,64 @@
package repository
import (
"context"
"errors"
"github.com/cloudprint/cloudprint-backend/internal/model"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *UserRepository) GetByID(ctx context.Context, id int32) (*model.User, error) {
var user model.User
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByOpenid(ctx context.Context, openid string) (*model.User, error) {
var user model.User
err := r.db.WithContext(ctx).Where("openid = ?", openid).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *UserRepository) UpdateBalance(ctx context.Context, userID int32, balance float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("balance", balance).Error
}
func (r *UserRepository) DeductBalance(ctx context.Context, userID int32, amount float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).
Where("id = ? AND balance >= ?", userID, amount).
Update("balance", gorm.Expr("balance - ?", amount)).Error
}
func (r *UserRepository) AddBalance(ctx context.Context, userID int32, amount float64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
Update("balance", gorm.Expr("balance + ?", amount)).Error
}

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)
}

View 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)
}

View 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
}

View 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)
}

View File

@@ -0,0 +1,502 @@
syntax = "proto3";
package cloudprint;
option go_package = "github.com/cloudprint/cloudprint-backend/proto/generated";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
// ============ 通用消息 ============
message Empty {}
message StatusResponse {
int32 code = 1;
string message = 2;
}
// ============ 用户相关 ============
message User {
int32 id = 1;
string openid = 2;
string nickname = 3;
string avatar = 4;
double balance = 5;
google.protobuf.Timestamp created_at = 6;
}
message LoginRequest {
string code = 1; // 微信授权码
}
message LoginResponse {
string access_token = 1;
string refresh_token = 2;
User user = 3;
}
message RefreshRequest {
string refresh_token = 1;
}
message RefreshResponse {
string access_token = 1;
}
message GetUserInfoRequest {}
message UpdateUserRequest {
string nickname = 1;
string avatar = 2;
}
// ============ 文件相关 ============
message FileInfo {
int32 id = 1;
string file_name = 2;
string file_path = 3;
string file_type = 4;
int64 file_size = 5;
string uploader_type = 6;
int32 uploader_id = 7;
google.protobuf.Timestamp created_at = 8;
}
message UploadRequest {
string file_name = 1;
string file_type = 2;
int64 file_size = 3;
bytes data = 4;
bool is_last_chunk = 5;
string chunk_id = 6;
int32 chunk_index = 7;
int32 total_chunks = 8;
}
message UploadResponse {
int32 file_id = 1;
string file_path = 2;
string file_url = 3;
}
message GetFileRequest {
int32 file_id = 1;
}
message FileData {
bytes data = 1;
bool is_last = 2;
}
message DeleteFileRequest {
int32 file_id = 1;
}
message WatermarkRequest {
int32 file_id = 1;
string text = 2;
float opacity = 3;
int32 position = 4; // 1: 左上 2: 右上 3: 左下 4: 右下 5: 居中
int32 font_size = 5;
}
message WatermarkResponse {
int32 file_id = 1;
string file_path = 2;
}
message GetFileListRequest {
string uploader_type = 1;
int32 uploader_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message FileListResponse {
repeated FileInfo files = 1;
int32 total = 2;
}
// ============ 订单相关 ============
message Order {
int32 id = 1;
string order_no = 2;
int32 user_id = 3;
int32 status = 4;
double total_amount = 5;
string remark = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp paid_at = 8;
repeated OrderItem items = 9;
}
message OrderItem {
int32 id = 1;
int32 order_id = 2;
int32 file_id = 3;
FileInfo file = 4;
int32 printer_id = 5;
int32 copies = 6;
bool color = 7;
bool duplex = 8;
string page_range = 9;
double price = 10;
}
message CreateOrderRequest {
repeated CreateOrderItem items = 1;
string remark = 2;
}
message CreateOrderItem {
int32 file_id = 1;
int32 printer_id = 2;
int32 copies = 3;
bool color = 4;
bool duplex = 5;
string page_range = 6;
}
message CreateOrderResponse {
string order_no = 1;
double total_amount = 2;
}
message GetOrderRequest {
string order_no = 1;
}
message GetOrderListRequest {
int32 user_id = 1;
int32 status = 2;
int32 page = 3;
int32 page_size = 4;
}
message OrderListResponse {
repeated Order orders = 1;
int32 total = 2;
}
message UpdateOrderStatusRequest {
string order_no = 1;
int32 status = 2;
}
message CancelOrderRequest {
string order_no = 1;
}
// ============ 打印机相关 ============
message Printer {
int32 id = 1;
string name = 2;
string display_name = 3;
bool is_active = 4;
int32 status = 5; // 0: 空闲 1: 打印中 2: 离线
google.protobuf.Timestamp last_heartbeat = 6;
}
message GetPrintersRequest {}
message PrinterListResponse {
repeated Printer printers = 1;
}
message GetPrinterStatusRequest {
int32 printer_id = 1;
}
message PrinterStatusResponse {
Printer printer = 1;
int32 queue_count = 2;
}
message RegisterPrinterRequest {
string name = 1;
string display_name = 2;
string api_key = 3;
}
message RegisterPrinterResponse {
int32 printer_id = 1;
string token = 2;
}
message PrinterHeartbeatRequest {
int32 printer_id = 1;
int32 status = 2;
string token = 3;
}
// ============ 打印任务相关 ============
message PrintJob {
int32 id = 1;
string job_no = 2;
int32 printer_id = 3;
int32 order_item_id = 4;
string status = 5; // pending/printing/completed/failed/cancelled
int32 progress = 6;
string error_msg = 7;
google.protobuf.Timestamp started_at = 8;
google.protobuf.Timestamp completed_at = 9;
OrderItem order_item = 10;
}
message SubmitPrintJobRequest {
int32 order_item_id = 1;
int32 printer_id = 2;
string file_path = 3;
int32 copies = 4;
bool color = 5;
bool duplex = 6;
string page_range = 7;
}
message SubmitPrintJobResponse {
string job_no = 1;
}
message CancelPrintJobRequest {
string job_no = 1;
}
message SubscribeJobsRequest {
int32 printer_id = 1;
string token = 2;
}
message ReportJobStatusRequest {
string job_no = 1;
string status = 2;
int32 progress = 3;
string error_msg = 4;
}
// ============ 文库相关 ============
message LibraryCategory {
int32 id = 1;
string name = 2;
string icon = 3;
int32 sort_order = 4;
}
message LibraryFile {
int32 id = 1;
int32 category_id = 2;
int32 file_id = 3;
string title = 4;
string description = 5;
int32 page_count = 6;
int32 download_count = 7;
FileInfo file = 8;
google.protobuf.Timestamp created_at = 9;
}
message GetCategoriesRequest {}
message CategoryListResponse {
repeated LibraryCategory categories = 1;
}
message GetLibraryFilesRequest {
int32 category_id = 1;
int32 page = 2;
int32 page_size = 3;
}
message LibraryFileListResponse {
repeated LibraryFile files = 1;
int32 total = 2;
}
message UploadLibraryFileRequest {
int32 category_id = 1;
string title = 2;
string description = 3;
bytes data = 4;
string file_name = 5;
string file_type = 6;
}
message UploadLibraryFileResponse {
int32 library_file_id = 1;
}
message DeleteLibraryFileRequest {
int32 library_file_id = 1;
}
// ============ 价目表相关 ============
message PriceItem {
int32 id = 1;
string name = 2;
string type = 3;
double price = 4;
string unit = 5;
bool color_enabled = 6;
bool is_active = 7;
}
message GetPriceListRequest {}
message PriceListResponse {
repeated PriceItem items = 1;
}
message CreatePriceItemRequest {
string name = 1;
string type = 2;
double price = 3;
string unit = 4;
bool color_enabled = 5;
}
message UpdatePriceItemRequest {
int32 id = 1;
string name = 2;
string type = 3;
double price = 4;
string unit = 5;
bool color_enabled = 6;
bool is_active = 7;
}
// ============ 支付相关 ============
message WxPayRequest {
string order_no = 1;
double amount = 2;
string description = 3;
string openid = 4;
string client_ip = 5;
}
message WxPayResponse {
string prepay_id = 1;
string code_url = 2;
string mweb_url = 3;
}
message QueryPayStatusRequest {
string order_no = 1;
}
message PayStatusResponse {
int32 status = 1; // 0: 待支付 1: 支付中 2: 成功 3: 失败
string transaction_id = 2;
}
message PayCallbackRequest {
string body = 1;
}
message PayCallbackResponse {
string return_code = 1;
string return_msg = 2;
}
message PayByBalanceRequest {
string order_no = 1;
int32 user_id = 2;
}
// ============ 管理后台相关 ============
message Admin {
int32 id = 1;
string username = 2;
string nickname = 3;
int32 role = 4;
google.protobuf.Timestamp created_at = 5;
}
message AdminLoginRequest {
string username = 1;
string password = 2;
}
message AdminLoginResponse {
string access_token = 1;
Admin admin = 2;
}
message GetStatisticsRequest {}
message Statistics {
int32 total_users = 1;
int32 total_orders = 2;
int32 today_orders = 3;
double today_revenue = 4;
int32 pending_orders = 5;
int32 active_printers = 6;
}
// ============ 服务定义 ============
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc RefreshToken(RefreshRequest) returns (RefreshResponse);
rpc GetUserInfo(GetUserInfoRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
}
service FileService {
rpc Upload(stream UploadRequest) returns (UploadResponse);
rpc GetFile(GetFileRequest) returns (stream FileData);
rpc DeleteFile(DeleteFileRequest) returns (Empty);
rpc AddWatermark(WatermarkRequest) returns (WatermarkResponse);
rpc GetFileList(GetFileListRequest) returns (FileListResponse);
}
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder(GetOrderRequest) returns (Order);
rpc GetOrderList(GetOrderListRequest) returns (OrderListResponse);
rpc UpdateOrderStatus(UpdateOrderStatusRequest) returns (Empty);
rpc CancelOrder(CancelOrderRequest) returns (Empty);
}
service PrintService {
rpc GetPrinters(GetPrintersRequest) returns (PrinterListResponse);
rpc GetPrinterStatus(GetPrinterStatusRequest) returns (PrinterStatusResponse);
rpc RegisterPrinter(RegisterPrinterRequest) returns (RegisterPrinterResponse);
rpc PrinterHeartbeat(PrinterHeartbeatRequest) returns (Empty);
rpc SubmitPrintJob(SubmitPrintJobRequest) returns (SubmitPrintJobResponse);
rpc CancelPrintJob(CancelPrintJobRequest) returns (Empty);
rpc SubscribeJobs(SubscribeJobsRequest) returns (stream PrintJob);
rpc ReportJobStatus(ReportJobStatusRequest) returns (Empty);
}
service LibraryService {
rpc GetCategories(GetCategoriesRequest) returns (CategoryListResponse);
rpc GetFiles(GetLibraryFilesRequest) returns (LibraryFileListResponse);
rpc UploadFile(stream UploadLibraryFileRequest) returns (UploadLibraryFileResponse);
rpc DeleteFile(DeleteLibraryFileRequest) returns (Empty);
}
service PriceService {
rpc GetPriceList(GetPriceListRequest) returns (PriceListResponse);
rpc CreatePriceItem(CreatePriceItemRequest) returns (PriceItem);
rpc UpdatePriceItem(UpdatePriceItemRequest) returns (PriceItem);
}
service PayService {
rpc CreateWxPayOrder(WxPayRequest) returns (WxPayResponse);
rpc QueryPayStatus(QueryPayStatusRequest) returns (PayStatusResponse);
rpc HandlePayCallback(PayCallbackRequest) returns (PayCallbackResponse);
rpc PayByBalance(PayByBalanceRequest) returns (StatusResponse);
}
service AdminService {
rpc Login(AdminLoginRequest) returns (AdminLoginResponse);
rpc GetStatistics(GetStatisticsRequest) returns (Statistics);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
-- 云印享 - 付费打印服务系统数据库初始化脚本
-- 数据库: cloudprint
CREATE DATABASE IF NOT EXISTS `cloudprint` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `cloudprint`;
-- 用户表
CREATE TABLE IF NOT EXISTS `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`openid` VARCHAR(64) UNIQUE NOT NULL COMMENT '微信OpenID',
`nickname` VARCHAR(128) DEFAULT '' COMMENT '昵称',
`avatar` VARCHAR(512) DEFAULT '' COMMENT '头像URL',
`balance` DECIMAL(10,2) DEFAULT 0.00 COMMENT '余额',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 管理员表
CREATE TABLE IF NOT EXISTS `admin` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(64) UNIQUE NOT NULL COMMENT '用户名',
`password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希',
`nickname` VARCHAR(128) DEFAULT '' COMMENT '昵称',
`role` TINYINT DEFAULT 1 COMMENT '1:普通管理员 2:超级管理员',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
-- 打印机表
CREATE TABLE IF NOT EXISTS `printer` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) UNIQUE NOT NULL COMMENT '系统打印机名',
`display_name` VARCHAR(128) NOT NULL COMMENT '显示名称',
`api_key` VARCHAR(64) UNIQUE NOT NULL COMMENT '客户端认证Key',
`is_active` TINYINT DEFAULT 1 COMMENT '是否启用',
`status` TINYINT DEFAULT 0 COMMENT '0:空闲 1:打印中 2:离线',
`last_heartbeat` DATETIME DEFAULT NULL COMMENT '最后心跳时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_name` (`name`),
INDEX `idx_api_key` (`api_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打印机表';
-- 订单表
CREATE TABLE IF NOT EXISTS `order` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
`user_id` INT NOT NULL COMMENT '用户ID',
`status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:已支付 2:打印中 3:已完成 4:已取消',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '总金额',
`remark` VARCHAR(512) DEFAULT '' COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`paid_at` DATETIME DEFAULT NULL COMMENT '支付时间',
INDEX `idx_order_no` (`order_no`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 订单明细表
CREATE TABLE IF NOT EXISTS `order_item` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_id` INT NOT NULL COMMENT '订单ID',
`file_id` INT NOT NULL COMMENT '文件ID',
`printer_id` INT DEFAULT NULL COMMENT '打印机ID',
`copies` INT DEFAULT 1 COMMENT '份数',
`color` TINYINT DEFAULT 0 COMMENT '是否彩色 0:黑白 1:彩色',
`duplex` TINYINT DEFAULT 0 COMMENT '是否双面 0:单面 1:双面',
`page_range` VARCHAR(64) DEFAULT 'all' COMMENT '页面范围',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
INDEX `idx_order_id` (`order_id`),
INDEX `idx_file_id` (`file_id`),
FOREIGN KEY (`order_id`) REFERENCES `order`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
-- 文件表
CREATE TABLE IF NOT EXISTS `file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`file_name` VARCHAR(256) NOT NULL COMMENT '文件名',
`file_path` VARCHAR(512) NOT NULL COMMENT '文件路径',
`file_type` VARCHAR(32) NOT NULL COMMENT '文件类型',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`uploader_type` VARCHAR(16) NOT NULL COMMENT '上传者类型: user/admin/system',
`uploader_id` INT NOT NULL COMMENT '上传者ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_uploader` (`uploader_type`, `uploader_id`),
INDEX `idx_file_type` (`file_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件表';
-- 文库分类表
CREATE TABLE IF NOT EXISTS `library_category` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL COMMENT '分类名称',
`icon` VARCHAR(128) DEFAULT '' COMMENT '图标',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文库分类表';
-- 文库文件表
CREATE TABLE IF NOT EXISTS `library_file` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`category_id` INT NOT NULL COMMENT '分类ID',
`file_id` INT NOT NULL COMMENT '文件ID',
`title` VARCHAR(256) NOT NULL COMMENT '标题',
`description` VARCHAR(512) DEFAULT '' COMMENT '描述',
`page_count` INT DEFAULT 0 COMMENT '页数',
`download_count` INT DEFAULT 0 COMMENT '下载次数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_category_id` (`category_id`),
INDEX `idx_file_id` (`file_id`),
FOREIGN KEY (`category_id`) REFERENCES `library_category`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文库文件表';
-- 打印任务表
CREATE TABLE IF NOT EXISTS `print_job` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`job_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '任务号',
`printer_id` INT NOT NULL COMMENT '打印机ID',
`order_item_id` INT NOT NULL COMMENT '订单明细ID',
`status` VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/printing/completed/failed/cancelled',
`progress` INT DEFAULT 0 COMMENT '进度(0-100)',
`error_msg` VARCHAR(512) DEFAULT '' COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
INDEX `idx_job_no` (`job_no`),
INDEX `idx_printer_id` (`printer_id`),
INDEX `idx_status` (`status`),
FOREIGN KEY (`printer_id`) REFERENCES `printer`(`id`) ON DELETE RESTRICT,
FOREIGN KEY (`order_item_id`) REFERENCES `order_item`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打印任务表';
-- 价目表
CREATE TABLE IF NOT EXISTS `price_list` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL COMMENT '项目名称',
`type` VARCHAR(32) NOT NULL COMMENT '纸张类型: A4/A3/照片纸',
`price` DECIMAL(10,2) NOT NULL COMMENT '单价',
`unit` VARCHAR(16) NOT NULL COMMENT '单位: 页/张/份',
`color_enabled` TINYINT DEFAULT 1 COMMENT '是否支持彩色',
`is_active` TINYINT DEFAULT 1 COMMENT '是否启用',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_type` (`type`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价目表';
-- 支付记录表
CREATE TABLE IF NOT EXISTS `payment` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
`pay_type` VARCHAR(16) DEFAULT 'wechat' COMMENT '支付类型: wechat/balance',
`pay_status` TINYINT DEFAULT 0 COMMENT '0:待支付 1:支付中 2:成功 3:失败',
`transaction_id` VARCHAR(64) DEFAULT '' COMMENT '微信交易号',
`paid_at` DATETIME DEFAULT NULL COMMENT '支付时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_order_no` (`order_no`),
INDEX `idx_pay_status` (`pay_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';
-- 初始化管理员账号 (密码: admin123)
-- 密码哈希使用 bcrypt 生成
INSERT INTO `admin` (`username`, `password_hash`, `nickname`, `role`) VALUES
('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrqJlmZ2j3.N9qo8uLOickgx2ZMRZoG', '系统管理员', 2);
-- 初始化默认价目表
INSERT INTO `price_list` (`name`, `type`, `price`, `unit`, `color_enabled`, `is_active`, `sort_order`) VALUES
('A4黑白打印', 'A4', 0.10, '', 0, 1, 1),
('A4彩色打印', 'A4', 0.30, '', 1, 1, 2),
('A3黑白打印', 'A3', 0.20, '', 0, 1, 3),
('A3彩色打印', 'A3', 0.60, '', 1, 1, 4),
('照片打印', '照片纸', 2.00, '', 1, 1, 5);
-- 初始化默认文库分类
INSERT INTO `library_category` (`name`, `icon`, `sort_order`) VALUES
('学习资料', 'book', 1),
('考试真题', 'exam', 2),
('简历模板', 'resume', 3),
('合同协议', 'contract', 4),
('其他资料', 'other', 5);