diff --git a/config.yaml b/config.yaml index c861bcd..1c7ea10 100644 --- a/config.yaml +++ b/config.yaml @@ -56,6 +56,15 @@ external_api: es_update_book_url: "https://book.center.yushutx.com/api/es/updateBookFieldsByISBN" # sync_task_url: "http://192.168.101.156:8080/task/create" sync_task_url: "http://36.212.7.246:8283/task/create" - # sync_task_body_url: "http://192.168.101.156:8080./task/setTaskBody" + # sync_task_body_url: "http://192.168.101.156:8080/task/setTaskBody" sync_task_body_url: "http://36.212.7.246:8283/task/setTaskBody" + timeout: 30 + +wangdian: + url: "https://api.wangdian.cn/openapi2/purchase_order_push.php" + sandbox_url: "https://sandbox.wangdian.cn/openapi2/purchase_order_push.php" + sandbox: true + sid: "apidevnew2" + appkey: "skxz2-test" + appsecret: "85bf423bb" timeout: 30 \ No newline at end of file diff --git a/config/config.go b/config/config.go index 21821e8..9a576fe 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ type Config struct { ES ESConfig `yaml:"es"` OCR OCRConfig `yaml:"ocr"` ExternalAPI ExternalAPIConfig `yaml:"external_api"` + Wangdian WangdianConfig `yaml:"wangdian"` } type ServerConfig struct { @@ -74,6 +75,16 @@ type ExternalAPIConfig struct { Timeout int `yaml:"timeout"` } +type WangdianConfig struct { + URL string `yaml:"url"` + SandboxURL string `yaml:"sandbox_url"` + Sandbox bool `yaml:"sandbox"` + Sid string `yaml:"sid"` + AppKey string `yaml:"appkey"` + AppSecret string `yaml:"appsecret"` + Timeout int `yaml:"timeout"` +} + var AppConfig *Config func Init() { diff --git a/controllers/wangdian.go b/controllers/wangdian.go new file mode 100644 index 0000000..26282ae --- /dev/null +++ b/controllers/wangdian.go @@ -0,0 +1,37 @@ +package controllers + +import ( + "net/http" + "psi/constant" + "psi/database" + "psi/service" + "psi/utils" + "strconv" + + "github.com/gin-gonic/gin" +) + +type WangdianApi struct{} + +func (i *WangdianApi) CreatePurchaseOrder(c *gin.Context) { + raw := c.PostForm("purchase_order_id") + if raw == "" { + raw = c.Query("purchase_order_id") + } + purchaseOrderID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || purchaseOrderID <= 0 { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "参数错误: purchase_order_id 必须为正整数", nil, c, nil) + return + } + + resp, err := service.PushPurchaseOrder(purchaseOrderID, database.GetDB(c)) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "推送采购单到旺店通失败: "+err.Error(), err, c, nil) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": resp.Code, + "message": resp.Message, + }) +} diff --git a/database/mysql.go b/database/mysql.go index 2fc2aa6..aa9866b 100644 --- a/database/mysql.go +++ b/database/mysql.go @@ -1,15 +1,16 @@ package database import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" "log" "os" "psi/config" "psi/models" "psi/utils" "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) var DB *gorm.DB diff --git a/routes/routes.go b/routes/routes.go index b308143..34b5ada 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -2,11 +2,12 @@ package router import ( "fmt" - "github.com/gin-gonic/gin" - "github.com/pkg/errors" "psi/config" "psi/controllers" "psi/middleware" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) var employeeApi = &controllers.EmployeeApi{} @@ -39,6 +40,7 @@ var splitAccountConfigApi = &controllers.SplitAccountConfigApi{} var splitAccountDeductionLogApi = &controllers.SplitAccountDeductionLogApi{} var configApi = &controllers.ConfigApi{} var cancelLogisticsApi = &controllers.CancelLogisticsApi{} +var wangdianApi = &controllers.WangdianApi{} func Run() { defer func() { @@ -105,6 +107,13 @@ func initRouter() (r *gin.Engine) { sign.POST("/logistics/cancel", cancelLogisticsApi.CancelLogistics) // 取消物流单号 } + // 需要认证(JWT)但不签名验证的接口 + authOnly := api.Group("") + authOnly.Use(middleware.JWTAuth()) + { + authOnly.POST("/wangdian/purchase-order-push", wangdianApi.CreatePurchaseOrder) // 推送采购单到旺店通 + } + // 需要认证的接口 auth := api.Group("") auth.Use(middleware.APISign()) diff --git a/service/wangdian.go b/service/wangdian.go new file mode 100644 index 0000000..2a0a738 --- /dev/null +++ b/service/wangdian.go @@ -0,0 +1,187 @@ +package service + +import ( + "encoding/json" + "fmt" + "log" + "psi/config" + "psi/database" + "psi/models" + "psi/utils" + "strconv" + "time" + + "gorm.io/gorm" +) + +type WangdianPurchaseInfo struct { + ProviderNo string `json:"provider_no"` + WarehouseNo string `json:"warehouse_no"` + OuterNo string `json:"outer_no"` + IsUseOuterNo int8 `json:"is_use_outer_no,omitempty"` + IsCheck int8 `json:"is_check,omitempty"` + Contact string `json:"contact,omitempty"` + PurchaseName string `json:"purchase_name,omitempty"` + Telno string `json:"telno,omitempty"` + ReceiveAddress string `json:"receive_address,omitempty"` + LogisticsType int8 `json:"logistics_type,omitempty"` // 暂未使用 运货方式(物流公司) + ExpectArriveTime string `json:"expect_arrive_time,omitempty"` + Remark string `json:"remark,omitempty"` + OtherFee float64 `json:"other_fee,omitempty"` // 暂未使用 其他费用 + PostFee float64 `json:"post_fee,omitempty"` // 暂未使用 邮资 + Prop1 string `json:"prop1,omitempty"` // 暂未使用 自定义属性1 + Prop2 string `json:"prop2,omitempty"` // 暂未使用 自定义属性2 + DetailsList []WangdianPurchaseDetailItem `json:"details_list"` +} + +type WangdianPurchaseDetailItem struct { + SpecNo string `json:"spec_no"` + Num float64 `json:"num"` + Price float64 `json:"price"` + Discount float64 `json:"discount,omitempty"` + Tax float64 `json:"tax,omitempty"` //暂未使用 税率 + TaxPrice float64 `json:"tax_price,omitempty"` //暂未使用 税后单价 + TaxAmount float64 `json:"tax_amount,omitempty"` //暂未使用 税后金额 + Remark string `json:"remark,omitempty"` + Prop1 string `json:"prop1,omitempty"` // 暂未使用 自定义属性1 + Prop2 string `json:"prop2,omitempty"` // 暂未使用 自定义属性2 + +} + +type WangdianPurchasePushResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func PushPurchaseOrder(purchaseOrderID int64, db ...*gorm.DB) (*WangdianPurchasePushResponse, error) { + databaseConn := database.OptionalDB(db...) + + dbName := databaseConn.Migrator().CurrentDatabase() + log.Printf("[旺店通推送] 使用数据库: %s", dbName) + + // 1. 查询采购单 + var po models.PurchaseOrder + if err := databaseConn.Where("id = ? AND is_del = 0", purchaseOrderID).First(&po).Error; err != nil { + return nil, fmt.Errorf("采购单不存在: %v", err) + } + + // 2. 查询供应商编号(必须) + var supplier models.Supplier + if err := databaseConn.Select("code, contact_person, contact_phone, address"). + Where("id = ? AND is_del = 0", po.SupplierID).First(&supplier).Error; err != nil { + return nil, fmt.Errorf("获取供应商信息失败: %v", err) + } + if supplier.Code == "" { + return nil, fmt.Errorf("供应商编码为空,请先维护供应商档案") + } + + // 3. 查询仓库编号(必须) + var warehouse models.Warehouse + if err := databaseConn.Select("code, contact_person, contact_phone, address"). + Where("id = ? AND is_del = 0", po.WarehouseID).First(&warehouse).Error; err != nil { + return nil, fmt.Errorf("获取仓库信息失败: %v", err) + } + if warehouse.Code == "" { + return nil, fmt.Errorf("仓库编码为空,请先维护仓库档案") + } + + // 4. 查询采购明细,关联商品条码 + type itemRow struct { + models.PurchaseOrderItem + Barcode string `gorm:"column:barcode"` + } + var items []itemRow + if err := databaseConn.Table("purchase_order_item"). + Select("purchase_order_item.*, product.barcode"). + Joins("LEFT JOIN product ON purchase_order_item.product_id = product.id AND product.is_del = 0"). + Where("purchase_order_item.purchase_order_id = ? AND purchase_order_item.is_del = 0", purchaseOrderID). + Debug().Scan(&items).Error; err != nil { + return nil, fmt.Errorf("获取采购明细失败: %v", err) + } + if len(items) == 0 { + return nil, fmt.Errorf("采购明细为空,无法推送") + } + + // 5. 组装 details_list + detailList := make([]WangdianPurchaseDetailItem, 0, len(items)) + for _, it := range items { + specNo := it.Barcode + if specNo == "" { + specNo = strconv.FormatInt(it.ProductID, 10) + } + detailList = append(detailList, WangdianPurchaseDetailItem{ + SpecNo: specNo, + Num: float64(it.Quantity), + Price: float64(it.UnitPrice) / 100.0, + Discount: 1.0, + }) + } + + // 6. 组装 purchase_info + info := WangdianPurchaseInfo{ + ProviderNo: supplier.Code, + WarehouseNo: warehouse.Code, + OuterNo: po.PoNo, + IsUseOuterNo: 0, + IsCheck: 0, + Contact: warehouse.ContactPerson, + PurchaseName: warehouse.ContactPerson, + Telno: warehouse.ContactPhone, + ReceiveAddress: warehouse.Address, + Remark: po.Remark, + DetailsList: detailList, + } + if po.ExpectedArrivalDate > 0 { + info.ExpectArriveTime = time.Unix(po.ExpectedArrivalDate, 0).Format("2006-01-02 15:04:05") + } + + infoBytes, err := json.Marshal(info) + if err != nil { + return nil, fmt.Errorf("序列化采购信息失败: %v", err) + } + + // 7. 校验配置 + cfg := config.AppConfig.Wangdian + if cfg.Sid == "" || cfg.AppKey == "" || cfg.AppSecret == "" { + return nil, fmt.Errorf("旺店通接口配置不完整(sid/appkey/appsecret)") + } + + // 8. 组装公共请求参数 + 计算签名 + timestamp := time.Now().Unix() + params := map[string]string{ + "sid": cfg.Sid, + "appkey": cfg.AppKey, + "timestamp": strconv.FormatInt(timestamp, 10), + "purchase_info": string(infoBytes), + } + params["sign"] = utils.WangdianSign(params, cfg.AppSecret) + + // 9. 提交请求 + url := cfg.URL + if cfg.Sandbox && cfg.SandboxURL != "" { + url = cfg.SandboxURL + } + if url == "" { + url = "https://api.wangdian.cn/openapi2/purchase_order_push.php" + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 + } + + log.Printf("[旺店通推送] 请求URL: %s (sandbox=%v, form-urlencoded), 参数: sid=%s appkey=%s timestamp=%s sign=%s", url, cfg.Sandbox, cfg.Sid, cfg.AppKey, params["timestamp"], params["sign"]) + log.Printf("[旺店通推送] purchase_info: %s", string(infoBytes)) + + respBody, err := utils.SubmitFormDataWithTimeout(url, params, timeout) + if err != nil { + return nil, fmt.Errorf("推送请求失败: %v", err) + } + + log.Printf("[旺店通推送] 返回信息: %s", string(respBody)) + + var resp WangdianPurchasePushResponse + if err := json.Unmarshal([]byte(respBody), &resp); err != nil { + return nil, fmt.Errorf("解析响应失败(raw=%s): %v", respBody, err) + } + return &resp, nil +} diff --git a/utils/helper.go b/utils/helper.go index a514d29..2452e0e 100644 --- a/utils/helper.go +++ b/utils/helper.go @@ -7,12 +7,15 @@ import ( "fmt" "github.com/gin-gonic/gin" "io" + "log" "math/rand" "mime/multipart" "net/http" + "net/url" "sort" "strings" "time" + "unicode/utf8" ) // GenerateEmployeeID 生成工号 (5位数字,不足补零) @@ -242,31 +245,23 @@ func SubmitMultiBody(url string, taskID string, bodyList []string, sign string) // @param params 表单数据 // @return error 错误信息 func SubmitFormData(url string, params map[string]string) (string, error) { - // 创建multipart writer body := &bytes.Buffer{} writer := multipart.NewWriter(body) - // 添加文本字段 for key, value := range params { err := writer.WriteField(key, value) if err != nil { return "", fmt.Errorf("write field error: %v", err) } } - - // 关闭writer writer.Close() - // 创建请求 req, err := http.NewRequest("POST", url, body) if err != nil { return "", fmt.Errorf("create request error: %v", err) } - - // 设置Content-Type req.Header.Set("Content-Type", writer.FormDataContentType()) - // 发送请求 client := &http.Client{} resp, err := client.Do(req) if err != nil { @@ -274,11 +269,76 @@ func SubmitFormData(url string, params map[string]string) (string, error) { } defer resp.Body.Close() - // 读取响应 respBody, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read response error: %v", err) } - return string(respBody), nil } + +// SubmitFormDataWithTimeout 提交表单数据(application/x-www-form-urlencoded) +func SubmitFormDataWithTimeout(requestURL string, params map[string]string, timeoutSeconds int) (string, error) { + form := url.Values{} + for key, value := range params { + form.Set(key, value) + } + + log.Printf("[旺店通推送] 发送form-urlencoded:\n%s", form.Encode()) + + req, err := http.NewRequest("POST", requestURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("create request error: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + timeout := time.Duration(timeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("send request error: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response error: %v", err) + } + return string(respBody), nil +} + +// WangdianSign 旺店通接口签名算法 +// 与PHP SDK保持一致,使用UTF-8字符长度(iconv_strlen)而非字节长度 +func WangdianSign(params map[string]string, appSecret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if k == "sign" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + var builder strings.Builder + for idx, key := range keys { + value := params[key] + keyLen := utf8.RuneCountInString(key) + valLen := utf8.RuneCountInString(value) + + builder.WriteString(fmt.Sprintf("%02d", keyLen)) + builder.WriteString("-") + builder.WriteString(key) + builder.WriteString(":") + builder.WriteString(fmt.Sprintf("%04d", valLen)) + builder.WriteString("-") + builder.WriteString(value) + if idx < len(keys)-1 { + builder.WriteString(";") + } + } + builder.WriteString(appSecret) + hash := md5.Sum([]byte(builder.String())) + return hex.EncodeToString(hash[:]) +}