diff --git a/Dockerfile.backend b/Dockerfile.backend index 06cdd01..4f3a18d 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,6 +1,7 @@ # Backend only Dockerfile FROM golang:1.23-alpine AS builder WORKDIR /app/backend +ENV GOPROXY=https://goproxy.cn,direct COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ @@ -14,4 +15,4 @@ COPY backend/configs/ ../configs/ # Use production config by default RUN ln -sf ../configs/config.prod.yaml config.yaml EXPOSE 18888 -CMD ["./license-manager"] \ No newline at end of file +CMD ["./license-manager"] diff --git a/backend/configs/config.dev.yaml b/backend/configs/config.dev.yaml index 5637947..da0c9c4 100644 --- a/backend/configs/config.dev.yaml +++ b/backend/configs/config.dev.yaml @@ -5,11 +5,11 @@ server: database: driver: "mysql" - host: "mysql" + host: "203.2.164.31" port: 3306 - username: "license_user" - password: "license@123" - database: "license_manager" + username: "license" + password: "8X3Yd8hQ4WMPw227" + database: "license" charset: "utf8mb4" parse_time: true loc: "Local" diff --git a/backend/internal/api/handlers/public_renewal_handler.go b/backend/internal/api/handlers/public_renewal_handler.go new file mode 100644 index 0000000..9b4982f --- /dev/null +++ b/backend/internal/api/handlers/public_renewal_handler.go @@ -0,0 +1,582 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "license-manager/internal/models" + "license-manager/internal/repository" + "license-manager/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type PublicRenewalHandler struct { + licenseRepo repository.LicenseRepository + customerRepo repository.CustomerRepository + cuUserRepo repository.CuUserRepository + packageService service.PackageService + cuOrderService service.CuOrderService + paymentService service.PaymentService +} + +func NewPublicRenewalHandler( + licenseRepo repository.LicenseRepository, + customerRepo repository.CustomerRepository, + cuUserRepo repository.CuUserRepository, + packageService service.PackageService, + cuOrderService service.CuOrderService, + paymentService service.PaymentService, +) *PublicRenewalHandler { + return &PublicRenewalHandler{ + licenseRepo: licenseRepo, + customerRepo: customerRepo, + cuUserRepo: cuUserRepo, + packageService: packageService, + cuOrderService: cuOrderService, + paymentService: paymentService, + } +} + +type publicAuthCodeQueryRequest struct { + AuthorizationCode string `json:"authorization_code" binding:"required"` +} + +type publicRenewalOrderRequest struct { + AuthorizationCode string `json:"authorization_code" binding:"required"` + PackageID string `json:"package_id" binding:"required"` + LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"` + RenewDays int `json:"renew_days" binding:"required,min=1,max=3650"` + PaymentMethod string `json:"payment_method"` + ContactName string `json:"contact_name" binding:"required,max=100"` + ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"` + ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"` + CompanyName string `json:"company_name" binding:"omitempty,max=200"` +} + +type publicPurchaseOrderRequest struct { + PackageID string `json:"package_id" binding:"required"` + LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"` + PaymentMethod string `json:"payment_method"` + ContactName string `json:"contact_name" binding:"required,max=100"` + ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"` + ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"` + CompanyName string `json:"company_name" binding:"omitempty,max=200"` + CustomerType string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` + Description string `json:"description" binding:"omitempty,max=500"` +} + +// QueryAuthorizationCode 公开查询授权码信息 +func (h *PublicRenewalHandler) QueryAuthorizationCode(c *gin.Context) { + var req publicAuthCodeQueryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "900001", + Message: "请求参数错误", + Timestamp: getCurrentTimestamp(), + }) + return + } + + code := strings.TrimSpace(req.AuthorizationCode) + authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), code) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse{ + Code: "300001", + Message: "授权码不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + + customerName := "" + contactPerson := "" + contactPhone := "" + contactEmail := "" + + customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID) + if err == nil { + customerName = customer.CustomerName + contactPerson = customer.ContactPerson + if customer.Phone != nil { + contactPhone = *customer.Phone + } + if customer.Email != nil { + contactEmail = *customer.Email + } + } + + activeCount, _ := h.licenseRepo.GetActiveLicenseCount(c.Request.Context(), authCode.ID) + + status := "normal" + now := time.Now() + if authCode.IsLocked { + status = "locked" + } else if now.After(authCode.EndDate) { + status = "expired" + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: "success", + Data: gin.H{ + "authorization_code_id": authCode.ID, + "authorization_code": authCode.Code, + "customer_id": authCode.CustomerID, + "customer_name": customerName, + "contact_person": contactPerson, + "contact_phone": contactPhone, + "contact_email": contactEmail, + "status": status, + "is_locked": authCode.IsLocked, + "start_date": authCode.StartDate.Format(time.RFC3339), + "end_date": authCode.EndDate.Format(time.RFC3339), + "max_activations": authCode.MaxActivations, + "active_licenses": activeCount, + }, + Timestamp: getCurrentTimestamp(), + }) +} + +// CreateRenewalOrder 公开创建续期支付单 +func (h *PublicRenewalHandler) CreateRenewalOrder(c *gin.Context) { + var req publicRenewalOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "900001", + Message: "请求参数错误", + Timestamp: getCurrentTimestamp(), + }) + return + } + + authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), strings.TrimSpace(req.AuthorizationCode)) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse{ + Code: "300001", + Message: "授权码不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + if authCode.IsLocked { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "300003", + Message: "授权码已锁定,无法续期", + Timestamp: getCurrentTimestamp(), + }) + return + } + + _, err = h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "800002", + Message: "套餐不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + + priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "601004", + Message: "价格计算失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID) + if err == nil { + if strings.TrimSpace(req.CompanyName) != "" { + customer.CustomerName = strings.TrimSpace(req.CompanyName) + } + customer.ContactPerson = strings.TrimSpace(req.ContactName) + phone := strings.TrimSpace(req.ContactPhone) + if phone != "" { + customer.Phone = &phone + } + email := strings.TrimSpace(req.ContactEmail) + if email != "" { + customer.Email = &email + } + _ = h.customerRepo.UpdateCustomer(c.Request.Context(), customer) + } + + cuUserID, err := h.ensureCuUser(c.Request.Context(), authCode.CustomerID, req.ContactPhone, req.ContactName, req.ContactEmail) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Code: "900004", + Message: "创建客户用户失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + renewalExtra := map[string]interface{}{ + "authorization_code_id": authCode.ID, + "renew_days": req.RenewDays, + } + extraDataBytes, _ := json.Marshal(renewalExtra) + extraData := string(extraDataBytes) + + paymentMethod := strings.TrimSpace(req.PaymentMethod) + if paymentMethod == "" { + paymentMethod = models.PaymentMethodAlipay + } + + businessID := authCode.ID + payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{ + BusinessType: models.BusinessTypeLicenseExtend, + BusinessID: &businessID, + CustomerID: authCode.CustomerID, + CuUserID: cuUserID, + Amount: priceResult.TotalAmount, + Currency: "CNY", + PaymentMethod: paymentMethod, + ExtraData: &extraData, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Code: "602001", + Message: "创建支付单失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: "success", + Data: gin.H{ + "payment_no": payment.PaymentNo, + "payment_url": payment.PaymentURL, + "total_amount": payment.Amount, + "expire_time": payment.ExpireTime.Format(time.RFC3339), + "renew_days": req.RenewDays, + }, + Timestamp: getCurrentTimestamp(), + }) +} + +// CreatePurchaseOrder 公开创建新购支付单 +func (h *PublicRenewalHandler) CreatePurchaseOrder(c *gin.Context) { + var req publicPurchaseOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "900001", + Message: "请求参数错误", + Timestamp: getCurrentTimestamp(), + }) + return + } + + pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "800002", + Message: "套餐不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + + priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "601004", + Message: "价格计算失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + customerID, cuUserID, err := h.ensureCustomerAndUser( + c.Request.Context(), + strings.TrimSpace(req.CompanyName), + strings.TrimSpace(req.ContactName), + strings.TrimSpace(req.ContactPhone), + strings.TrimSpace(req.ContactEmail), + strings.TrimSpace(req.CustomerType), + strings.TrimSpace(req.Description), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Code: "900004", + Message: "客户信息写入失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + orderReq := &models.CuOrderCreateRequest{ + PackageID: req.PackageID, + LicenseCount: req.LicenseCount, + PaymentMethod: strings.TrimSpace(req.PaymentMethod), + } + if orderReq.PaymentMethod == "" { + orderReq.PaymentMethod = models.PaymentMethodAlipay + } + + if priceResult.TotalAmount == 0 { + order, createErr := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, orderReq) + if createErr != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "601001", + Message: "订单创建失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: "success", + Data: gin.H{ + "order_id": order.ID, + "order_no": order.OrderNo, + "package_name": pkg.Name, + "total_amount": 0, + "license_count": req.LicenseCount, + "authorization_code": order.AuthorizationCode, + }, + Timestamp: getCurrentTimestamp(), + }) + return + } + + order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, orderReq) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "601001", + Message: "订单创建失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{ + BusinessType: models.BusinessTypePackageOrder, + BusinessID: &order.ID, + CustomerID: customerID, + CuUserID: cuUserID, + Amount: priceResult.TotalAmount, + Currency: "CNY", + PaymentMethod: orderReq.PaymentMethod, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Code: "602001", + Message: "支付单创建失败", + Timestamp: getCurrentTimestamp(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: "success", + Data: gin.H{ + "order_id": order.ID, + "order_no": order.OrderNo, + "package_name": pkg.Name, + "payment_no": payment.PaymentNo, + "payment_url": payment.PaymentURL, + "total_amount": payment.Amount, + "expire_time": payment.ExpireTime.Format(time.RFC3339), + "license_count": req.LicenseCount, + }, + Timestamp: getCurrentTimestamp(), + }) +} + +// GetPublicPaymentStatus 公开支付状态查询 +func (h *PublicRenewalHandler) GetPublicPaymentStatus(c *gin.Context) { + paymentNo := strings.TrimSpace(c.Param("payment_no")) + if paymentNo == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Code: "900001", + Message: "支付单号不能为空", + Timestamp: getCurrentTimestamp(), + }) + return + } + + statusResp, err := h.paymentService.GetPaymentStatus(c.Request.Context(), paymentNo) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse{ + Code: "602002", + Message: "支付单不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + + payment, err := h.paymentService.GetPayment(c.Request.Context(), paymentNo) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse{ + Code: "602002", + Message: "支付单不存在", + Timestamp: getCurrentTimestamp(), + }) + return + } + + authCodeID := "" + endDate := "" + if payment.ExtraData != nil && strings.TrimSpace(*payment.ExtraData) != "" { + var renewalData struct { + AuthorizationCodeID string `json:"authorization_code_id"` + } + if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err == nil { + authCodeID = renewalData.AuthorizationCodeID + if strings.TrimSpace(authCodeID) != "" { + authCode, err := h.licenseRepo.GetAuthorizationCodeByID(c.Request.Context(), authCodeID) + if err == nil { + endDate = authCode.EndDate.Format(time.RFC3339) + } + } + } + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: "success", + Data: gin.H{ + "payment_no": statusResp.PaymentNo, + "status": statusResp.Status, + "amount": statusResp.Amount, + "payment_time": statusResp.PaymentTime, + "trade_no": statusResp.TradeNo, + "authorization_code_id": authCodeID, + "authorization_end_date": endDate, + "business_order": statusResp.BusinessOrder, + }, + Timestamp: getCurrentTimestamp(), + }) +} + +func (h *PublicRenewalHandler) ensureCuUser(ctx context.Context, customerID, phone, contactName, contactEmail string) (string, error) { + countryCode := "+86" + trimmedPhone := strings.TrimSpace(phone) + user, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode) + if err == nil { + return user.ID, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + + var realName *string + name := strings.TrimSpace(contactName) + if name != "" { + realName = &name + } + var email *string + trimmedEmail := strings.TrimSpace(contactEmail) + if trimmedEmail != "" { + email = &trimmedEmail + } + + newUser := &models.CuUser{ + CustomerID: customerID, + Phone: trimmedPhone, + PhoneCountryCode: countryCode, + UserRole: "member", + RealName: realName, + Email: email, + Status: "active", + PhoneVerified: true, + EmailVerified: false, + Language: "zh-CN", + Timezone: "Asia/Shanghai", + } + if err := h.cuUserRepo.Create(newUser); err != nil { + return "", err + } + return newUser.ID, nil +} + +func (h *PublicRenewalHandler) ensureCustomerAndUser( + ctx context.Context, + companyName, contactName, phone, email, customerType, description string, +) (string, string, error) { + countryCode := "+86" + trimmedPhone := strings.TrimSpace(phone) + existingUser, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode) + if err == nil { + customer, customerErr := h.customerRepo.GetCustomerByID(ctx, existingUser.CustomerID) + if customerErr == nil { + if companyName != "" { + customer.CustomerName = companyName + } + if contactName != "" { + customer.ContactPerson = contactName + } + if trimmedPhone != "" { + customer.Phone = &trimmedPhone + } + if email != "" { + customer.Email = &email + } + if description != "" { + customer.Description = &description + } + _ = h.customerRepo.UpdateCustomer(ctx, customer) + } + return existingUser.CustomerID, existingUser.ID, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", err + } + + ct := customerType + if ct == "" { + ct = "enterprise" + } + customerName := companyName + if customerName == "" { + customerName = contactName + } + if customerName == "" { + customerName = trimmedPhone + } + contactPerson := contactName + if contactPerson == "" { + contactPerson = trimmedPhone + } + + customer := &models.Customer{ + CustomerName: customerName, + CustomerType: ct, + ContactPerson: contactPerson, + CustomerLevel: "basic", + Status: "active", + CreatedBy: "public-order", + } + if trimmedPhone != "" { + customer.Phone = &trimmedPhone + } + if email != "" { + customer.Email = &email + } + if description != "" { + customer.Description = &description + } + if err := h.customerRepo.CreateCustomer(ctx, customer); err != nil { + return "", "", err + } + + cuUserID, err := h.ensureCuUser(ctx, customer.ID, trimmedPhone, contactName, email) + if err != nil { + return "", "", err + } + return customer.ID, cuUserID, nil +} diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go index d3816b2..202690b 100644 --- a/backend/internal/api/routes/router.go +++ b/backend/internal/api/routes/router.go @@ -121,6 +121,7 @@ func SetupRouter() *gin.Engine { packageHandler := handlers.NewPackageHandler(packageService) leadService := service.NewLeadService(leadRepo, db) leadHandler := handlers.NewLeadHandler(leadService) + publicRenewalHandler := handlers.NewPublicRenewalHandler(licenseRepo, customerRepo, cuUserRepo, packageService, cuOrderService, paymentService) // 健康检测接口(无需认证) router.GET("/health", systemHandler.HealthCheck) @@ -151,6 +152,10 @@ func SetupRouter() *gin.Engine { // 公共发票下载接口 public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken) + public.POST("/public/authorization-codes/query", publicRenewalHandler.QueryAuthorizationCode) + public.POST("/public/orders", publicRenewalHandler.CreatePurchaseOrder) + public.POST("/public/renewals/order", publicRenewalHandler.CreateRenewalOrder) + public.GET("/public/payment/:payment_no/status", publicRenewalHandler.GetPublicPaymentStatus) // 线索收集(无需鉴权) public.POST("/leads", leadHandler.CreateLead) diff --git a/backend/internal/models/payment.go b/backend/internal/models/payment.go index 98f42a5..c2a0912 100644 --- a/backend/internal/models/payment.go +++ b/backend/internal/models/payment.go @@ -61,6 +61,7 @@ type PaymentCreateRequest struct { Amount float64 `json:"amount" binding:"required,min=0.01"` Currency string `json:"currency"` PaymentMethod string `json:"payment_method"` + ExtraData *string `json:"extra_data,omitempty"` } // PaymentResponse 支付响应结构 diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 38644d3..e41f0ad 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -127,6 +127,7 @@ func (s *paymentService) CreatePayment(ctx context.Context, req *models.PaymentC PaymentProvider: req.PaymentMethod, Status: models.PaymentStatusPending, ExpireTime: expireTime, + ExtraData: req.ExtraData, } // 如果是支付宝,生成支付URL @@ -279,6 +280,42 @@ func (s *paymentService) ProcessAlipayCallback(ctx context.Context, values url.V } // 支付成功:根据业务类型处理 + if payment.BusinessType == models.BusinessTypeLicenseExtend { + if payment.ExtraData == nil || strings.TrimSpace(*payment.ExtraData) == "" { + return nil + } + + var renewalData struct { + AuthorizationCodeID string `json:"authorization_code_id"` + RenewDays int `json:"renew_days"` + } + if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err != nil { + return err + } + if strings.TrimSpace(renewalData.AuthorizationCodeID) == "" || renewalData.RenewDays <= 0 { + return nil + } + + var authCode models.AuthorizationCode + if err := tx.Where("id = ?", renewalData.AuthorizationCodeID).First(&authCode).Error; err != nil { + return err + } + + baseDate := authCode.EndDate + now := time.Now() + if baseDate.Before(now) { + baseDate = now + } + newEndDate := baseDate.AddDate(0, 0, renewalData.RenewDays) + + return tx.Model(&models.AuthorizationCode{}). + Where("id = ?", authCode.ID). + Updates(map[string]interface{}{ + "end_date": newEndDate, + "updated_at": now, + }).Error + } + if payment.BusinessType != models.BusinessTypePackageOrder || payment.BusinessID == nil || strings.TrimSpace(*payment.BusinessID) == "" { return nil } diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml new file mode 100644 index 0000000..a5f2a9b --- /dev/null +++ b/docker-compose.registry.yml @@ -0,0 +1,40 @@ +version: '3.8' + +services: + backend: + image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-backend:${VERSION:-latest} + container_name: license-manager-backend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + - GIN_MODE=release + ports: + - "18888:18888" + volumes: + - ./backend/configs:/app/backend/configs + - ./logs:/app/logs + - ./backend/configs/config.prod.yaml:/app/backend/cmd/config.yaml:ro + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:18888/health || exit 1"] + interval: 10s + timeout: 10s + retries: 6 + start_period: 60s + + frontend: + image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-frontend:${VERSION:-latest} + container_name: license-manager-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "18080:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/docs/docker-package-publish.md b/docs/docker-package-publish.md new file mode 100644 index 0000000..e3578a2 --- /dev/null +++ b/docs/docker-package-publish.md @@ -0,0 +1,35 @@ +# Docker 镜像推送说明 + +项目已经补充了镜像推送脚本 `scripts/docker-push.sh`,默认面向本地 Gitea/私有仓库地址 `localhost:9988`。 + +## 1. 登录镜像仓库 + +```bash +docker login localhost:9988 +``` + +如果你的镜像仓库命名空间不是 `leeexin`,推送前修改环境变量 `NAMESPACE`。 + +## 2. 构建并推送镜像 + +```bash +chmod +x scripts/docker-push.sh +REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 ./scripts/docker-push.sh +``` + +推送完成后会得到两份镜像: + +- `localhost:9988/leeexin/license-manager-backend:v1.1.1` +- `localhost:9988/leeexin/license-manager-frontend:v1.1.1` + +## 3. 使用推送后的镜像部署 + +```bash +REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 docker compose -f docker-compose.registry.yml up -d +``` + +## 4. 支付配置提醒 + +- 生产环境镜像启动时,支付配置以 `backend/configs/config.prod.yaml` 为准。 +- 当前已经保留支付宝正式环境字段,请把 `app_id`、密钥、回调域名替换为你的正式配置。 +- 免登录购买与续期入口为 `/public-renewal`,支付成功后可通过授权码查询和续期结果页面查看状态。 diff --git a/frontend/src/api/package.ts b/frontend/src/api/package.ts index 1b68171..0c13a96 100644 --- a/frontend/src/api/package.ts +++ b/frontend/src/api/package.ts @@ -7,7 +7,7 @@ export interface Package { price: number; price_description?: string; duration_description: string; - status: number; // 1: enabled, 2: disabled + status: number; // 1: enabled, 0: disabled type: string; sort_order: number; description?: string; @@ -51,3 +51,26 @@ export function getPackageDetail(id: string | number): Promise): Promise> { return Axios.put(`/api/packages/${id}`, data) } + +/** + * 创建套餐 + */ +export function createPackage(data: Partial): Promise> { + return Axios.post('/api/packages', data) +} + +/** + * 删除套餐 + */ +export function deletePackage(id: string | number): Promise> { + return Axios.delete(`/api/packages/${id}`) +} + +/** + * 更新套餐状态 + */ +export function updatePackageStatus(id: string | number, status: 0 | 1): Promise> { + return Axios.put(`/api/packages/${id}/status`, null, { + params: { status } + }) +} diff --git a/frontend/src/api/publicRenewal.ts b/frontend/src/api/publicRenewal.ts new file mode 100644 index 0000000..e95a076 --- /dev/null +++ b/frontend/src/api/publicRenewal.ts @@ -0,0 +1,107 @@ +import Axios from './https' + +export interface PublicApiResponse { + code: string + message: string + timestamp: string + data?: T +} + +export interface AuthorizationCodeInfo { + authorization_code_id: string + authorization_code: string + customer_id: string + customer_name: string + contact_person: string + contact_phone: string + contact_email: string + status: 'normal' | 'expired' | 'locked' + is_locked: boolean + start_date: string + end_date: string + max_activations: number + active_licenses: number +} + +export interface RenewalOrderRequest { + authorization_code: string + package_id: string + license_count: number + renew_days: number + payment_method?: string + contact_name: string + contact_phone: string + contact_email?: string + company_name?: string +} + +export interface RenewalOrderResponse { + order_id?: string + order_no?: string + package_name?: string + authorization_code?: string + payment_no?: string + payment_url?: string + total_amount: number + expire_time?: string + renew_days?: number + license_count?: number +} + +export interface PublicPaymentStatus { + payment_no: string + status: 'pending' | 'paid' | 'cancelled' | 'expired' | 'failed' + amount: number + payment_time?: string + trade_no?: string + authorization_code_id?: string + authorization_end_date?: string + business_order?: { + order_no: string + authorization_code?: string + status: string + } +} + +export interface PublicPackageItem { + id: string + name: string + type: string + price: number + display_price: string + price_description: string +} + +export function queryAuthorizationCode(authorizationCode: string): Promise> { + return Axios.post('/api/public/authorization-codes/query', { + authorization_code: authorizationCode + }) +} + +export function createRenewalOrder(payload: RenewalOrderRequest): Promise> { + return Axios.post('/api/public/renewals/order', payload) +} + +export interface PurchaseOrderRequest { + package_id: string + license_count: number + payment_method?: string + contact_name: string + contact_phone: string + contact_email?: string + company_name?: string + customer_type?: string + description?: string +} + +export function createPurchaseOrder(payload: PurchaseOrderRequest): Promise> { + return Axios.post('/api/public/orders', payload) +} + +export function getPublicPaymentStatus(paymentNo: string): Promise> { + return Axios.get(`/api/public/payment/${paymentNo}/status`) +} + +export function getPublicPackages(): Promise> { + return Axios.get('/api/cu/packages') +} diff --git a/frontend/src/components/common/icons/SidebarIcon.vue b/frontend/src/components/common/icons/SidebarIcon.vue index f1186d7..9e1a9e4 100644 --- a/frontend/src/components/common/icons/SidebarIcon.vue +++ b/frontend/src/components/common/icons/SidebarIcon.vue @@ -38,7 +38,11 @@ const iconMap = { 'invoices': InvoicesIcon, 'enterprise-leads': EnterpriseLeadsIcon, 'roles': RolesIcon, - 'users': UsersIcon + 'users': UsersIcon, + 'packages': InvoicesIcon, + 'payments': InvoicesIcon, + 'product': InvoicesIcon, + 'system': RolesIcon } const iconComponent = computed(() => { @@ -73,4 +77,4 @@ const iconClass = computed(() => ({ .sidebar-icon--active :deep(path) { fill: #019C7C; } - \ No newline at end of file + diff --git a/frontend/src/components/common/layout/Layout.vue b/frontend/src/components/common/layout/Layout.vue index 34896d3..1110a19 100644 --- a/frontend/src/components/common/layout/Layout.vue +++ b/frontend/src/components/common/layout/Layout.vue @@ -30,10 +30,10 @@ + + diff --git a/frontend/src/views/FeatureTemplates.vue b/frontend/src/views/FeatureTemplates.vue new file mode 100644 index 0000000..1610d64 --- /dev/null +++ b/frontend/src/views/FeatureTemplates.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 3fb7dc2..d04053e 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -132,6 +132,7 @@ @@ -327,6 +328,10 @@ function handleForgotPassword() { ElMessage.info(t('login.forgotPasswordAlert')) } +function goPublicRenewal() { + router.push('/public-renewal') +} + async function handleLogin() { if (!loginFormRef.value) return try { diff --git a/frontend/src/views/MenuManagement.vue b/frontend/src/views/MenuManagement.vue new file mode 100644 index 0000000..0abd1f3 --- /dev/null +++ b/frontend/src/views/MenuManagement.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/src/views/Packages/components/PackageEditDialog.vue b/frontend/src/views/Packages/components/PackageEditDialog.vue index 77884c2..6acac2d 100644 --- a/frontend/src/views/Packages/components/PackageEditDialog.vue +++ b/frontend/src/views/Packages/components/PackageEditDialog.vue @@ -1,6 +1,6 @@ @@ -86,7 +86,7 @@ + + diff --git a/frontend/src/views/ProductItems.vue b/frontend/src/views/ProductItems.vue new file mode 100644 index 0000000..0597386 --- /dev/null +++ b/frontend/src/views/ProductItems.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/views/PublicRenewal.vue b/frontend/src/views/PublicRenewal.vue new file mode 100644 index 0000000..2299325 --- /dev/null +++ b/frontend/src/views/PublicRenewal.vue @@ -0,0 +1,558 @@ + + + + + diff --git a/frontend/src/views/Roles.vue b/frontend/src/views/Roles.vue index c23cdf3..00acafb 100644 --- a/frontend/src/views/Roles.vue +++ b/frontend/src/views/Roles.vue @@ -1,59 +1,147 @@ - - - - \ No newline at end of file diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 7c91a92..cd75374 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -1,57 +1,156 @@ - - \ No newline at end of file +.page-header h1 { margin: 0; font-size: 28px; } +.page-header p { margin: 8px 0 0; color: #6b7280; } +.data-table { background: #fff; border-radius: 16px; overflow: hidden; } + diff --git a/scripts/docker-push.sh b/scripts/docker-push.sh new file mode 100755 index 0000000..f70cc36 --- /dev/null +++ b/scripts/docker-push.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REGISTRY="${REGISTRY:-localhost:9988}" +NAMESPACE="${NAMESPACE:-leeexin}" +VERSION="${VERSION:-latest}" + +BACKEND_IMAGE="${REGISTRY}/${NAMESPACE}/license-manager-backend:${VERSION}" +FRONTEND_IMAGE="${REGISTRY}/${NAMESPACE}/license-manager-frontend:${VERSION}" + +echo "Building backend and frontend images..." +cd "$ROOT_DIR" +docker compose build backend frontend + +echo "Tagging images..." +docker tag license-manager-backend:latest "$BACKEND_IMAGE" +docker tag license-manager-frontend:latest "$FRONTEND_IMAGE" + +echo "Pushing $BACKEND_IMAGE" +docker push "$BACKEND_IMAGE" + +echo "Pushing $FRONTEND_IMAGE" +docker push "$FRONTEND_IMAGE" + +cat <