Browse Source

Initial qiyuesuo-client SDK (v0.1.0).

Thin Go client for Qiyuesuo: auth, user, sign, and callback parsing over contract_lock_sdk.

Co-authored-by: Cursor <cursoragent@cursor.com>
郭铭泽 20 hours ago
commit
b2b08b49bb
12 changed files with 1197 additions and 0 deletions
  1. 7 0
      .gitignore
  2. 95 0
      README.md
  3. 142 0
      auth.go
  4. 97 0
      callback.go
  5. 190 0
      client.go
  6. 16 0
      config.go
  7. 25 0
      errors.go
  8. 10 0
      go.mod
  9. 73 0
      go.sum
  10. 229 0
      sign.go
  11. 148 0
      types.go
  12. 165 0
      user.go

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+# Binaries
+*.exe
+*.test
+
+# Go workspace
+go.work
+go.work.sum

+ 95 - 0
README.md

@@ -0,0 +1,95 @@
+# qiyuesuo-client
+
+契约锁(Qiyuesuo)开放平台 **Go 客户端 SDK**,封装 HTTP 签名、认证授权、PDF 静默签章与回调解密。
+
+业务状态机(租户入驻、成员加入、续签入口)由 **NGTenantManage / 各业务服务** 实现;本库 **仅负责契约锁 API 调用**。
+
+## 模块路径
+
+```text
+git.sxidc.com/health-checkup-system/qiyuesuo-client
+```
+
+## 依赖
+
+- `git.sxidc.com/student-physical-examination/contract_lock_sdk` — 官方 DTO 与 `SdkClient`(创建合同、静默签署)
+- `github.com/google/uuid` — 请求 nonce
+
+## 快速开始
+
+```go
+import qiyuesuosdk "git.sxidc.com/health-checkup-system/qiyuesuo-client"
+
+client := qiyuesuosdk.New(qiyuesuosdk.Config{
+    Address:   "https://qys.example.com",
+    AppToken:  "...",
+    AppSecret: "...",
+}, qiyuesuosdk.SignDefaults{
+    ProcessID:  "用印流程ID",
+    TenantName: "发起方企业名称",
+})
+
+// 机构认证页
+url, err := client.CompanyCertificationURL(qiyuesuosdk.OrgCertParams{
+    CompanyName: "某某医院", Charger: "张三", Mobile: "13800000000",
+    OpenCompanyID: tenantID, // 通常等于平台 tenant_id
+})
+
+// PDF 签章
+signed, err := client.SignDocument(qiyuesuosdk.SignDocumentRequest{
+    PDF: []byte("..."), Subject: "体检报告",
+    CompanySeal: qiyuesuosdk.CompanySealSpec{
+        CompanyName: "某某医院", Keyword: "报告日期", OffsetY: "0.03",
+    },
+    PersonalSeals: []qiyuesuosdk.PersonalSealSpec{
+        {OpenUserID: openID, Keyword: "李医生"},
+    },
+})
+
+// 回调解析(在 Gateway/ngtm  handler 中)
+event, err := client.ParseCallback(qiyuesuosdk.CallbackRequest{...})
+```
+
+## API 分组
+
+| 文件 | 能力 |
+|------|------|
+| `auth.go` | 机构法人认证、机构印章静默授权、授权记录查询 |
+| `user.go` | 内部用户创建、个人静默授权 URL、个人授权记录 |
+| `sign.go` | `SignDocument`(机构+个人)、`SignDualCompany`(双机构) |
+| `callback.go` | 回调 AES 解密 + SHA256 验签 |
+
+## 与各服务的关系
+
+| 服务 | 职责 |
+|------|------|
+| **qiyuesuo-client** | 契约锁 HTTP/SDK 封装 |
+| **NGTenantManage** | 租户/成员电子签章状态、认证授权 API、回调写库 |
+| **HealthCheckService** | 签章前查 ngtm 状态 → 调 `SignDocument` → 写 `med_sign_reports` |
+| **WeChatService** | 小程序「电子签名授权/续签」页面(调 ngtm,不直接依赖本库亦可) |
+
+## 版本发布
+
+```bash
+git tag v0.1.0
+git push origin v0.1.0
+```
+
+业务服务:
+
+```bash
+go get git.sxidc.com/health-checkup-system/qiyuesuo-client@v0.1.0
+```
+
+私服需配置 `GOPRIVATE=git.sxidc.com`。
+
+## 本地联调(monorepo)
+
+```go
+replace git.sxidc.com/health-checkup-system/qiyuesuo-client => ../qiyuesuo-client
+replace git.sxidc.com/student-physical-examination/contract_lock_sdk => ../contract_lock_sdk
+```
+
+## 文档
+
+- [docs/hcs/qiyuesuo-integration-plan.md](../docs/hcs/qiyuesuo-integration-plan.md) — 对接方案与职责划分

+ 142 - 0
auth.go

@@ -0,0 +1,142 @@
+package qiyuesuosdk
+
+import (
+	"time"
+
+	v2auth_request "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2auth/request"
+	v2auth_response "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2auth/response"
+	"git.sxidc.com/student-physical-examination/contract_lock_sdk/model/common"
+)
+
+type companyCertPageResp struct {
+	apiResponse
+	AuthURL string `json:"authurl"`
+}
+
+type companyCertStatusResp struct {
+	apiResponse
+	Result CompanyAuthStatus `json:"result"`
+}
+
+type companySealAuthURLResp struct {
+	apiResponse
+	Result v2auth_response.V2AuthCompanysignsilentUrlResponse `json:"result"`
+}
+
+type companyAuthRecordResp struct {
+	apiResponse
+	Result []common.AuthorizedSealRecordBean `json:"result"`
+}
+
+// CompanyCertificationURL 获取法人单位在线认证页 URL。
+func (c *Client) CompanyCertificationURL(p OrgCertParams) (string, error) {
+	if p.OpenCompanyID == "" || p.Mobile == "" || p.CompanyName == "" || p.Charger == "" {
+		return "", ErrInvalidParams
+	}
+	type reqBody struct {
+		Name          string   `json:"name"`
+		Charger       string   `json:"charger"`
+		Mobile        string   `json:"mobile"`
+		OpenCompanyId string   `json:"openCompanyId"`
+		Modes         []string `json:"modes"`
+		Customer      bool     `json:"customer"`
+	}
+	fields, err := structFormFields(reqBody{
+		Name:          p.CompanyName,
+		Charger:       p.Charger,
+		Mobile:        p.Mobile,
+		OpenCompanyId: p.OpenCompanyID,
+		Modes:         []string{"AUTHFILE", "BANKPAY"},
+		Customer:      true,
+	}, ",")
+	if err != nil {
+		return "", err
+	}
+	var resp companyCertPageResp
+	err = c.postMultipart("/companyauth/pcpage", fields, "", nil, &resp)
+	if err != nil {
+		return "", err
+	}
+	if err = resp.err(); err != nil {
+		return "", err
+	}
+	return resp.AuthURL, nil
+}
+
+// CompanyCertificationStatus 查询法人单位认证状态;未注册时 found=false。
+func (c *Client) CompanyCertificationStatus(openCompanyID string) (*CompanyAuthStatus, bool, error) {
+	var resp companyCertStatusResp
+	err := c.postMultipart("/companyauth/status", map[string]string{
+		"openCompanyId": openCompanyID,
+	}, "", nil, &resp)
+	if err != nil {
+		return nil, false, err
+	}
+	if err = resp.err(); err != nil {
+		if resp.Code == 2002002 {
+			return nil, false, nil
+		}
+		return nil, false, err
+	}
+	return &resp.Result, true, nil
+}
+
+// CompanySealAuthURL 获取机构印章静默授权页;authEnd 为空则默认 1 年。
+func (c *Client) CompanySealAuthURL(openCompanyID, adminMobile string, authEnd time.Time) (string, error) {
+	if authEnd.IsZero() {
+		authEnd = time.Now().Add(DefaultCompanySealAuthDuration)
+	}
+	timeEditable := false
+	req := v2auth_request.V2AuthCompanysignsilentUrlRequest{
+		SealMultipleRequest: &common.SealMultipleRequest{
+			Company: &common.CompanyRequest{OpenCompanyId: openCompanyID},
+		},
+		AuthUser:           &common.SilentUserRequest{Mobile: adminMobile},
+		AuthInformation:    []string{"AUTHORIZE_SEAL", "AUTHORIZE_COMPANY_CERTIFICATE"},
+		AuthorizedMode:     []string{"FACEAUTH", "PINAUTH"},
+		AuthEndDate:        authEnd.Format("2006-01-02"),
+		AuthTimeModifiable: &timeEditable,
+	}
+	var resp companySealAuthURLResp
+	if err := c.postJSON("/v2/auth/companysignsilent/url", req, &resp); err != nil {
+		return "", err
+	}
+	if err := resp.err(); err != nil {
+		return "", err
+	}
+	return resp.Result.Url, nil
+}
+
+// QueryCompanySealAuthRecords 查询机构静默授权记录;status 传 "EFFECT" 查有效授权。
+func (c *Client) QueryCompanySealAuthRecords(openCompanyID, status string) ([]AuthRecord, error) {
+	req := v2auth_request.V2AuthSignsilentCompanyRecordRequest{
+		AuthCompany: &common.CompanyRequest{OpenCompanyId: openCompanyID},
+		Status:      status,
+	}
+	var resp companyAuthRecordResp
+	if err := c.postJSON("/v2/auth/signsilent/company/record", req, &resp); err != nil {
+		return nil, err
+	}
+	if err := resp.err(); err != nil {
+		return nil, err
+	}
+	out := make([]AuthRecord, 0, len(resp.Result))
+	for _, item := range resp.Result {
+		out = append(out, AuthRecord{
+			Status:    item.AuthorizedSealRecord.Status,
+			StartTime: item.AuthorizedSealRecord.StartTime,
+			EndTime:   item.AuthorizedSealRecord.EndTime,
+			AuthScope: item.AuthorizedSealRecord.AuthScope,
+		})
+	}
+	return out, nil
+}
+
+// IsCompanySealAuthorized 是否存在有效机构印章静默授权。
+func (c *Client) IsCompanySealAuthorized(openCompanyID string) (bool, error) {
+	records, err := c.QueryCompanySealAuthRecords(openCompanyID, "EFFECT")
+	if err != nil {
+		return false, err
+	}
+	return len(records) > 0, nil
+}

+ 97 - 0
callback.go

@@ -0,0 +1,97 @@
+package qiyuesuosdk
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+)
+
+type callbackMeta struct {
+	CallbackType    string `json:"callbackType"`
+	CallbackTime    string `json:"callbackTime"`
+	CallbackBizType string `json:"callbackBizType"`
+}
+
+// ParseCallback 解密、验签并解析契约锁回调。
+func (c *Client) ParseCallback(req CallbackRequest) (CallbackEvent, error) {
+	plain, err := c.decryptCallback(req.Encrypted, c.cfg.CallbackToken, c.cfg.CallbackAESKey)
+	if err != nil {
+		return CallbackEvent{}, err
+	}
+	hash := sha256.Sum256([]byte(plain + req.Timestamp + req.Nonce + c.cfg.CallbackToken))
+	if hex.EncodeToString(hash[:]) != req.Signature {
+		return CallbackEvent{}, fmt.Errorf("qiyuesuo: callback signature mismatch")
+	}
+	return parseCallbackPayload(plain)
+}
+
+func parseCallbackPayload(plain string) (CallbackEvent, error) {
+	raw := []byte(plain)
+	var meta callbackMeta
+	if err := json.Unmarshal(raw, &meta); err != nil {
+		return CallbackEvent{}, err
+	}
+	switch EventType(meta.CallbackType) {
+	case EventCompanyAuthSuccess:
+		var data CompanyCertificationData
+		if err := json.Unmarshal(raw, &data); err != nil {
+		 return CallbackEvent{}, err
+		}
+		data.CallbackTime = meta.CallbackTime
+		return CallbackEvent{Type: EventCompanyAuthSuccess, Data: data}, nil
+	case EventCompanySealAuthSuccess:
+		var data CompanySealAuthData
+		if err := json.Unmarshal(raw, &data); err != nil {
+			return CallbackEvent{}, err
+		}
+		data.CallbackTime = meta.CallbackTime
+		return CallbackEvent{Type: EventCompanySealAuthSuccess, Data: data}, nil
+	case EventPersonalSealAuthSuccess:
+		var data PersonalSealAuthData
+		if err := json.Unmarshal(raw, &data); err != nil {
+			return CallbackEvent{}, err
+		}
+		data.CallbackTime = meta.CallbackTime
+		return CallbackEvent{Type: EventPersonalSealAuthSuccess, Data: data}, nil
+	case EventCallbackCheck:
+		return CallbackEvent{Type: EventCallbackCheck, Data: nil}, nil
+	default:
+		return CallbackEvent{}, fmt.Errorf("qiyuesuo: unknown callback type %q", meta.CallbackType)
+	}
+}
+
+func (c *Client) decryptCallback(encrypted, token, aesKey string) (string, error) {
+	ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
+	if err != nil {
+		return "", fmt.Errorf("qiyuesuo: base64 decode: %w", err)
+	}
+	key := []byte(aesKey)
+	if len(key) < 16 {
+		return "", fmt.Errorf("qiyuesuo: aes key too short")
+	}
+	key = key[:16]
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return "", err
+	}
+	ivHash := sha256.Sum256([]byte(token))
+	iv := ivHash[:aes.BlockSize]
+	if len(ciphertext)%aes.BlockSize != 0 {
+		return "", fmt.Errorf("qiyuesuo: invalid ciphertext block size")
+	}
+	mode := cipher.NewCBCDecrypter(block, iv)
+	plain := make([]byte, len(ciphertext))
+	mode.CryptBlocks(plain, ciphertext)
+	if len(plain) == 0 {
+		return "", fmt.Errorf("qiyuesuo: empty plaintext")
+	}
+	padding := int(plain[len(plain)-1])
+	if padding <= 0 || padding > len(plain) {
+		return "", fmt.Errorf("qiyuesuo: invalid pkcs padding")
+	}
+	return string(plain[:len(plain)-padding]), nil
+}

+ 190 - 0
client.go

@@ -0,0 +1,190 @@
+package qiyuesuosdk
+
+import (
+	"bytes"
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"git.sxidc.com/student-physical-examination/contract_lock_sdk/utils"
+	"github.com/google/uuid"
+)
+
+// Client 契约锁开放平台客户端;仅封装 HTTP/签名与契约锁 API,不含业务状态机。
+type Client struct {
+	cfg          Config
+	signDefaults SignDefaults
+	sdk          *utils.SdkClient
+	http         *http.Client
+}
+
+// New 创建客户端。signDefaults 可为零值,签章时可按请求单独传入 ProcessID/Launcher。
+func New(cfg Config, signDefaults SignDefaults) *Client {
+	address := strings.TrimRight(cfg.Address, "/")
+	return &Client{
+		cfg:          cfg,
+		signDefaults: signDefaults,
+		sdk:          utils.NewSdkClient(address, cfg.AppToken, cfg.AppSecret),
+		http:         &http.Client{Timeout: 60 * time.Second},
+	}
+}
+
+func (c *Client) baseURL() string {
+	return strings.TrimRight(c.cfg.Address, "/")
+}
+
+func (c *Client) authHeaders() map[string]string {
+	nonce := uuid.NewString()
+	timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
+	raw := c.cfg.AppToken + c.cfg.AppSecret + timestamp + nonce
+	sum := md5.Sum([]byte(raw))
+	return map[string]string{
+		"x-qys-accesstoken": c.cfg.AppToken,
+		"x-qys-timestamp":   timestamp,
+		"x-qys-nonce":       nonce,
+		"x-qys-signature":   hex.EncodeToString(sum[:]),
+	}
+}
+
+func (c *Client) postJSON(path string, body any, out any) error {
+	payload, err := json.Marshal(body)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequest(http.MethodPost, c.baseURL()+path, bytes.NewReader(payload))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json;charset=UTF-8")
+	for k, v := range c.authHeaders() {
+		req.Header.Set(k, v)
+	}
+	return c.doJSON(req, out)
+}
+
+func (c *Client) getQuery(path string, query url.Values, out any) error {
+	u := c.baseURL() + path
+	if len(query) > 0 {
+		u += "?" + query.Encode()
+	}
+	req, err := http.NewRequest(http.MethodGet, u, nil)
+	if err != nil {
+		return err
+	}
+	for k, v := range c.authHeaders() {
+		req.Header.Set(k, v)
+	}
+	return c.doJSON(req, out)
+}
+
+func (c *Client) doJSON(req *http.Request, out any) error {
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	if out == nil {
+		return nil
+	}
+	return json.Unmarshal(data, out)
+}
+
+func (c *Client) postMultipart(path string, fields map[string]string, fileField string, fileBytes []byte, out any) error {
+	var buf bytes.Buffer
+	w := multipart.NewWriter(&buf)
+	for k, v := range fields {
+		if err := w.WriteField(k, v); err != nil {
+			return err
+		}
+	}
+	if len(fileBytes) > 0 && fileField != "" {
+		part, err := w.CreateFormFile(fileField, "document.pdf")
+		if err != nil {
+			return err
+		}
+		if _, err = part.Write(fileBytes); err != nil {
+			return err
+		}
+	}
+	if err := w.Close(); err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(http.MethodPost, c.baseURL()+path, &buf)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", w.FormDataContentType())
+	for k, v := range c.authHeaders() {
+		req.Header.Set(k, v)
+	}
+	return c.doJSON(req, out)
+}
+
+func (c *Client) download(path string, query url.Values) ([]byte, error) {
+	u := c.baseURL() + path
+	if len(query) > 0 {
+		u += "?" + query.Encode()
+	}
+	req, err := http.NewRequest(http.MethodGet, u, nil)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range c.authHeaders() {
+		req.Header.Set(k, v)
+	}
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	return io.ReadAll(resp.Body)
+}
+
+func (c *Client) serviceOK(resp *utils.SdkResponse) error {
+	if resp == nil {
+		return fmt.Errorf("qiyuesuo: empty sdk response")
+	}
+	if resp.Message == "SUCCESS" {
+		return nil
+	}
+	return fmt.Errorf("qiyuesuo: %s", resp.Message)
+}
+
+func (c *Client) serviceString(resp *utils.SdkResponse) (string, error) {
+	if err := c.serviceOK(resp); err != nil {
+		return "", err
+	}
+	if resp.Result == nil {
+		return "", nil
+	}
+	if s, ok := resp.Result.(string); ok {
+		return s, nil
+	}
+	return "", fmt.Errorf("qiyuesuo: unexpected result type %T", resp.Result)
+}
+
+func (c *Client) resolveProcessID(id string) string {
+	if id != "" {
+		return id
+	}
+	return c.signDefaults.ProcessID
+}
+
+func (c *Client) resolveLauncher(name string) string {
+	if name != "" {
+		return name
+	}
+	return c.signDefaults.TenantName
+}

+ 16 - 0
config.go

@@ -0,0 +1,16 @@
+package qiyuesuosdk
+
+// Config 契约锁开放平台连接与回调验签配置。
+type Config struct {
+	Address        string // 契约锁服务根地址,如 https://qys.example.com
+	AppToken       string
+	AppSecret      string
+	CallbackToken  string // 回调验签 token
+	CallbackAESKey string // 回调 AES 密钥(取前 16 字节)
+}
+
+// SignDefaults 创建合同/发起签署时的默认用印流程参数(由业务服务配置注入)。
+type SignDefaults struct {
+	ProcessID  string // 用印流程 categoryId
+	TenantName string // 发起方企业名称
+}

+ 25 - 0
errors.go

@@ -0,0 +1,25 @@
+package qiyuesuosdk
+
+import "errors"
+
+var (
+	// ErrCompanyNotRegistered 契约锁返回 code=2002002,表示 openCompanyId 尚未注册。
+	ErrCompanyNotRegistered = errors.New("qiyuesuo: company not registered")
+	// ErrInvalidParams 必填参数缺失。
+	ErrInvalidParams = errors.New("qiyuesuo: invalid params")
+)
+
+type apiResponse struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+}
+
+func (r apiResponse) err() error {
+	if r.Message == "SUCCESS" || r.Message == "" {
+		return nil
+	}
+	if r.Code == 2002002 {
+		return ErrCompanyNotRegistered
+	}
+	return errors.New("qiyuesuo: " + r.Message)
+}

+ 10 - 0
go.mod

@@ -0,0 +1,10 @@
+module git.sxidc.com/health-checkup-system/qiyuesuo-client
+
+go 1.22
+
+require (
+	git.sxidc.com/student-physical-examination/contract_lock_sdk v0.0.0-20250705084345-056db4cca05c
+	github.com/google/uuid v1.6.0
+)
+
+require github.com/tjfoc/gmsm v1.4.1 // indirect

+ 73 - 0
go.sum

@@ -0,0 +1,73 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+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/envoyproxy/go-control-plane v0.9.0/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/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/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.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
+github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+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-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-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+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-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-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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+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-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+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/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+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.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 229 - 0
sign.go

@@ -0,0 +1,229 @@
+package qiyuesuosdk
+
+import (
+	"net/url"
+	"strconv"
+
+	contract_request "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/contract/request"
+	"git.sxidc.com/student-physical-examination/contract_lock_sdk/model/common"
+	v2contract_request "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2contract/request"
+	v2document_response "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2document/response"
+)
+
+// SignDocument 上传 PDF → 创建合同 → 企业静默签 → [个人静默签] → 下载。
+func (c *Client) SignDocument(req SignDocumentRequest) ([]byte, error) {
+	processID := c.resolveProcessID(req.ProcessID)
+	launcher := c.resolveLauncher(req.Launcher)
+	if processID == "" || launcher == "" {
+		return nil, ErrInvalidParams
+	}
+
+	docID, err := c.uploadDocument(req.Subject, req.PDF)
+	if err != nil {
+		return nil, err
+	}
+
+	openUserIDs := make([]string, 0, len(req.PersonalSeals))
+	for _, p := range req.PersonalSeals {
+		openUserIDs = append(openUserIDs, p.OpenUserID)
+	}
+	contractID, err := c.createContract(processID, req.Subject, launcher, docID, req.CompanySeal.CompanyName, openUserIDs)
+	if err != nil {
+		return nil, err
+	}
+	if err = c.companySilentSign(contractID, req.CompanySeal); err != nil {
+		return nil, err
+	}
+	for _, p := range req.PersonalSeals {
+		if err = c.personalSilentSign(contractID, p); err != nil {
+			return nil, err
+		}
+	}
+	return c.downloadDocument(docID)
+}
+
+// SignDualCompany 双企业公章签署(无个人签)。
+func (c *Client) SignDualCompany(req SignDualCompanyRequest) ([]byte, error) {
+	processID := c.resolveProcessID(req.ProcessID)
+	launcher := c.resolveLauncher(req.Launcher)
+	if processID == "" || launcher == "" || len(req.CompanySeals) == 0 {
+		return nil, ErrInvalidParams
+	}
+
+	docID, err := c.uploadDocument(req.Subject, req.PDF)
+	if err != nil {
+		return nil, err
+	}
+	names := make([]string, len(req.CompanySeals))
+	for i, s := range req.CompanySeals {
+		names[i] = s.CompanyName
+	}
+	contractID, err := c.createDualCompanyContract(processID, req.Subject, launcher, docID, names)
+	if err != nil {
+		return nil, err
+	}
+	for _, seal := range req.CompanySeals {
+		if err = c.companySilentSign(contractID, seal); err != nil {
+			return nil, err
+		}
+	}
+	return c.downloadDocument(docID)
+}
+
+func (c *Client) uploadDocument(title string, pdf []byte) (string, error) {
+	fields := map[string]string{
+		"title":    title,
+		"fileType": "pdf",
+	}
+	var resp struct {
+		apiResponse
+		Result v2document_response.V2DocumentCreatebyfileResponse `json:"result"`
+	}
+	if err := c.postMultipart("/v2/document/createbyfile", fields, "file", pdf, &resp); err != nil {
+		return "", err
+	}
+	if err := resp.err(); err != nil {
+		return "", err
+	}
+	return resp.Result.DocumentId, nil
+}
+
+func (c *Client) createContract(processID, subject, launcher, documentID, companyName string, openUserIDs []string) (string, error) {
+	docInt, err := strconv.ParseInt(documentID, 10, 64)
+	if err != nil {
+		return "", err
+	}
+	send := true
+	remind := false
+	var serialNo int64 = 1
+	signatories := []*common.Signatory{{
+		TenantName: companyName,
+		TenantType: "COMPANY",
+		SerialNo:   &serialNo,
+		Remind:     &remind,
+	}}
+	for _, uid := range openUserIDs {
+		serialNo++
+		sNo := serialNo
+		signatories = append(signatories, &common.Signatory{
+			TenantType: "PERSONAL",
+			SerialNo:   &sNo,
+			Remind:     &remind,
+			OpenUserId: uid,
+		})
+	}
+	req := contract_request.ContractCreatebycategoryRequest{
+		CategoryId:  processID,
+		Subject:     subject,
+		Send:        &send,
+		TenantName:  launcher,
+		Documents:   []int64{docInt},
+		Signatories: signatories,
+	}
+	var contractID string
+	resp := c.sdk.ServiceAsModel(req, contractID)
+	id, err := c.serviceString(resp)
+	return id, err
+}
+
+func (c *Client) createDualCompanyContract(processID, subject, launcher, documentID string, companyNames []string) (string, error) {
+	docInt, err := strconv.ParseInt(documentID, 10, 64)
+	if err != nil {
+		return "", err
+	}
+	send := true
+	remind := false
+	var serialNo int64 = 1
+	var actionSerial int64 = 1
+	signatories := make([]*common.Signatory, 0, len(companyNames))
+	for _, name := range companyNames {
+		sNo := serialNo
+		serialNo++
+		aNo := actionSerial
+		signatories = append(signatories, &common.Signatory{
+			TenantName: name,
+			TenantType: "COMPANY",
+			SerialNo:   &sNo,
+			Remind:     &remind,
+			Actions: []*common.Action{{
+				Type_:    "CORPORATE",
+				SerialNo: &aNo,
+			}},
+		})
+	}
+	req := contract_request.ContractCreatebycategoryRequest{
+		CategoryId:  processID,
+		Subject:     subject,
+		Send:        &send,
+		TenantName:  launcher,
+		Documents:   []int64{docInt},
+		Signatories: signatories,
+	}
+	var contractID string
+	resp := c.sdk.ServiceAsModel(req, contractID)
+	id, err := c.serviceString(resp)
+	return id, err
+}
+
+func (c *Client) companySilentSign(contractID string, spec CompanySealSpec) error {
+	cInt, err := strconv.ParseInt(contractID, 10, 64)
+	if err != nil {
+		return err
+	}
+	stamper := &common.CompanyStamperBean{
+		Type_:        "SEAL_CORPORATE",
+		Keyword:      spec.Keyword,
+		OffsetX:      spec.OffsetX,
+		OffsetY:      spec.OffsetY,
+	}
+	if spec.RotateDegree != 0 {
+		stamper.RotateDegree = &spec.RotateDegree
+	}
+	if spec.Page != "" {
+		stamper.Page = spec.Page
+	}
+	req := v2contract_request.V2ContractSignbycompanyRequest{
+		Contract: &common.SignSilentContract{Id: &cInt},
+		Company:  &common.CompanyRequest{Name: spec.CompanyName},
+		SealRequest: &common.SealMultipleRequest{
+			Company: &common.CompanyRequest{Name: spec.CompanyName},
+		},
+		Stampers: []*common.CompanyStamperBean{stamper},
+	}
+	return c.serviceOK(c.sdk.Service(req))
+}
+
+func (c *Client) personalSilentSign(contractID string, spec PersonalSealSpec) error {
+	cInt, err := strconv.ParseInt(contractID, 10, 64)
+	if err != nil {
+		return err
+	}
+	width, height := 12.0, 5.0
+	offsetX, offsetY := -30.0, 16.0
+	useDefault := true
+	req := v2contract_request.V2ContractSignbypersonRequest{
+		Contract: &common.SignSilentContract{Id: &cInt},
+		User:     &common.User{OpenUserId: spec.OpenUserID},
+		SealInfoRequest: &common.PersonSealInfoRequest{
+			User:                  &common.UserInfoRequest{OpenUserId: spec.OpenUserID},
+			DefaultPersonSignFlag: &useDefault,
+			PersonSealCarrier:     "PERSON_SIGN",
+		},
+		Stampers: []*common.StamperBean{{
+			Type_:        "SEAL_PERSONAL",
+			Keyword:      spec.Keyword,
+			KeywordIndex: "0",
+			Width:        &width,
+			Height:       &height,
+			OffsetX:      &offsetX,
+			OffsetY:      &offsetY,
+			OffsetUnit:   "POINT",
+		}},
+	}
+	return c.serviceOK(c.sdk.Service(req))
+}
+
+func (c *Client) downloadDocument(documentID string) ([]byte, error) {
+	q := url.Values{"documentId": {documentID}}
+	return c.download("/document/download", q)
+}

+ 148 - 0
types.go

@@ -0,0 +1,148 @@
+package qiyuesuosdk
+
+import "time"
+
+// OrgCertParams 机构法人认证页参数;OpenCompanyID 通常等于平台 tenant_id。
+type OrgCertParams struct {
+	CompanyName   string
+	Charger       string
+	Mobile        string
+	OpenCompanyID string
+}
+
+// CompanyAuthStatus 法人单位认证状态查询结果。
+type CompanyAuthStatus struct {
+	CompanyName string
+	RegisterNo  string
+	LegalPerson string
+	Charger     string
+	Mobile      string
+	AuthEndTime string
+	Status      string // result.status,AUTH_PASSED 表示认证成功
+	Reason      string
+}
+
+// CreateUserParams 在契约锁创建内部用户(医生等需个人签名的成员)。
+type CreateUserParams struct {
+	Name       string
+	OpenUserID string
+	AccountNo  string
+	Password   string
+	Mobile     string
+}
+
+// UserInfo 契约锁用户基本信息。
+type UserInfo struct {
+	Name string
+}
+
+// CompanySealSpec 企业公章关键字签署位置。
+type CompanySealSpec struct {
+	CompanyName  string
+	Keyword      string
+	OffsetX      string
+	OffsetY      string
+	RotateDegree int64
+	Page         string // 空则不限页;双章场景可设 "1"
+}
+
+// PersonalSealSpec 个人签名关键字签署位置。
+type PersonalSealSpec struct {
+	OpenUserID string
+	Keyword    string
+}
+
+// SignDocumentRequest PDF 静默签章请求(机构章 + 可选个人签)。
+type SignDocumentRequest struct {
+	PDF           []byte
+	Subject       string
+	ProcessID     string // 空则使用 Client 初始化时的 SignDefaults
+	Launcher      string
+	CompanySeal   CompanySealSpec
+	PersonalSeals []PersonalSealSpec
+}
+
+// SignDualCompanyRequest 双企业公章签署(如账单合同甲乙双方)。
+type SignDualCompanyRequest struct {
+	PDF            []byte
+	Subject        string
+	ProcessID      string
+	Launcher       string
+	CompanySeals   []CompanySealSpec
+}
+
+// Callback request / event types — 与契约锁开放平台事件名一致。
+
+const (
+	EventCompanyAuthSuccess     = "COMPANY_AUTH_SUCCESS"
+	EventCompanySealAuthSuccess = "COMPANY_SIGN_SILENT_AUTH"
+	EventUserAuthSuccess        = "USER_AUTH_SUCCESS"
+	EventPersonalSealAuthSuccess = "PERSONAL_SIGN_SILENT_AUTH"
+	EventCallbackCheck          = "CALLBACK_CHECK"
+)
+
+// CallbackRequest 契约锁 HTTP 回调原始载荷。
+type CallbackRequest struct {
+	Signature string `json:"signature"`
+	Timestamp string `json:"timestamp"`
+	Nonce     string `json:"nonce"`
+	Encrypted string `json:"encrypted"`
+}
+
+// CallbackEvent 解密验签后的回调事件。
+type CallbackEvent struct {
+	Type EventType
+	Data any
+}
+
+// EventType 回调事件类型。
+type EventType string
+
+// CompanyCertificationData 机构认证成功回调。
+type CompanyCertificationData struct {
+	CompanyID      string `json:"companyId"`
+	CompanyName    string `json:"companyName"`
+	OpenCompanyID  string `json:"openCompanyId"`
+	RegisterNo     string `json:"registerNo"`
+	ApplicantName  string `json:"applicantName"`
+	ApplicantPhone string `json:"applicantPhone"`
+	AuthEndTime    string `json:"authEndTime"`
+	CallbackTime   string `json:"callbackTime"`
+}
+
+// CompanySealAuthData 机构印章静默授权回调。
+type CompanySealAuthData struct {
+	UserName        string `json:"userName"`
+	UserMobile      string `json:"userMobile"`
+	AuthTime        string `json:"authTime"`
+	Status          string `json:"status"`
+	AuthCompanyID   string `json:"authCompanyId"`
+	AuthCompanyName string `json:"authCompanyName"`
+	CallbackTime    string `json:"callbackTime"`
+}
+
+// PersonalSealAuthData 个人签名静默授权回调。
+type PersonalSealAuthData struct {
+	UserName   string `json:"userName"`
+	UserMobile string `json:"userMobile"`
+	UserEmail  string `json:"userEmail"`
+	UserID     string `json:"userId"`
+	OpenUserID string `json:"openUserId"`
+	AuthTime   string `json:"authTime"`
+	Status     string `json:"status"`
+	CallbackTime string `json:"callbackTime"`
+}
+
+// AuthRecord 静默授权记录摘要。
+type AuthRecord struct {
+	Status    string
+	StartTime string
+	EndTime   string
+	AuthScope string
+}
+
+// DefaultPersonalAuthDuration 个人静默授权默认时长(旧系统 10 个月,避免跨年重叠)。
+const DefaultPersonalAuthDuration = 10 * 30 * 24 * time.Hour
+
+// DefaultCompanySealAuthDuration 机构印章静默授权默认时长(旧系统 1 年)。
+const DefaultCompanySealAuthDuration = 365 * 24 * time.Hour

+ 165 - 0
user.go

@@ -0,0 +1,165 @@
+package qiyuesuosdk
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"git.sxidc.com/student-physical-examination/contract_lock_sdk/model/common"
+	user_request "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/user/request"
+	v2auth_request "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2auth/request"
+	v2auth_response "git.sxidc.com/student-physical-examination/contract_lock_sdk/model/v2auth/response"
+)
+
+// FindUserByAccountNo 按 accountNo(通常等于 openUserId)查询用户。
+func (c *Client) FindUserByAccountNo(accountNo string) (*UserInfo, error) {
+	type result struct {
+		Name string `json:"name"`
+	}
+	var resp struct {
+		apiResponse
+		Result result `json:"result"`
+	}
+	q := url.Values{"accountNo": {accountNo}}
+	if err := c.getQuery("/user", q, &resp); err != nil {
+		return nil, err
+	}
+	if err := resp.err(); err != nil {
+		return nil, err
+	}
+	if resp.Result.Name == "" {
+		return nil, nil
+	}
+	return &UserInfo{Name: resp.Result.Name}, nil
+}
+
+// CreateInternalUser 在契约锁创建内部用户。
+func (c *Client) CreateInternalUser(p CreateUserParams) error {
+	req := user_request.UserV2CreateRequest{
+		Name:       p.Name,
+		OpenUserId: p.OpenUserID,
+		AccountNo:  p.AccountNo,
+		Password:   p.Password,
+		Mobile:     p.Mobile,
+	}
+	if req.AccountNo == "" {
+		req.AccountNo = p.OpenUserID
+	}
+	var resp apiResponse
+	if err := c.postJSON("/user/v2/create", req, &resp); err != nil {
+		return err
+	}
+	return resp.err()
+}
+
+// CreateInternalUserSkipMobileConflict 创建用户;手机号冲突时去掉手机号重试(旧系统行为)。
+func (c *Client) CreateInternalUserSkipMobileConflict(p CreateUserParams) error {
+	err := c.CreateInternalUser(p)
+	if err == nil {
+		return nil
+	}
+	if p.Mobile != "" && strings.Contains(err.Error(), "手机号已被其他用户绑定") {
+		p.Mobile = ""
+		return c.CreateInternalUser(p)
+	}
+	return err
+}
+
+// UserSignSilentURL 获取个人签名静默授权页;authEnd 为空则默认 10 个月。
+func (c *Client) UserSignSilentURL(openUserID string, authEnd time.Time, completeToPage string) (string, error) {
+	if authEnd.IsZero() {
+		authEnd = time.Now().Add(DefaultPersonalAuthDuration)
+	}
+	timeEditable := false
+	req := v2auth_request.V2AuthPersonalsignsilentUrlRequest{
+		AuthUser:           &common.SilentUserRequest{OpenUserId: openUserID},
+		AuthEndDate:        authEnd.Format("2006-01-02"),
+		AuthTimeModifiable: &timeEditable,
+		AuthorizedMode:     []string{"FACEAUTH", "PINAUTH"},
+		CompleteToPage:     completeToPage,
+	}
+	var resp struct {
+		apiResponse
+		Result v2auth_response.V2AuthPersonalsignsilentUrlResponse `json:"result"`
+	}
+	if err := c.postJSON("/v2/auth/personalsignsilent/url", req, &resp); err != nil {
+		return "", err
+	}
+	if err := resp.err(); err != nil {
+		return "", err
+	}
+	return resp.Result.Url, nil
+}
+
+// QueryPersonalSignAuthRecords 查询个人静默授权记录。
+func (c *Client) QueryPersonalSignAuthRecords(openUserID, status string) ([]AuthRecord, error) {
+	req := v2auth_request.V2AuthSignsilentRecordRequest{
+		AuthUser: &common.UserInfoRequest{OpenUserId: openUserID},
+		Status:   status,
+	}
+	var resp struct {
+		apiResponse
+		Result []v2auth_response.V2AuthSignsilentRecordResponse `json:"result"`
+	}
+	if err := c.postJSON("/v2/auth/signSilent/record", req, &resp); err != nil {
+		return nil, err
+	}
+	if err := resp.err(); err != nil {
+		return nil, err
+	}
+	out := make([]AuthRecord, 0, len(resp.Result))
+	for _, item := range resp.Result {
+		out = append(out, AuthRecord{
+			Status:  item.Status,
+			EndTime: item.AuthEndDate,
+			AuthScope: item.AuthScope,
+		})
+	}
+	return out, nil
+}
+
+// IsUserSignAuthorized 是否存在有效个人静默授权。
+func (c *Client) IsUserSignAuthorized(openUserID string) (bool, error) {
+	records, err := c.QueryPersonalSignAuthRecords(openUserID, "EFFECT")
+	if err != nil {
+		return false, err
+	}
+	return len(records) > 0, nil
+}
+
+// structFormFields 将 struct 转为 multipart 表单字段(与旧系统 HttpMultipart 行为一致)。
+func structFormFields(v any, arrayConnector string) (map[string]string, error) {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return nil, err
+	}
+	var raw map[string]any
+	if err = json.Unmarshal(b, &raw); err != nil {
+		return nil, err
+	}
+	out := make(map[string]string, len(raw))
+	for k, val := range raw {
+		switch t := val.(type) {
+		case nil:
+			continue
+		case string:
+			out[k] = t
+		case bool:
+			out[k] = strconv.FormatBool(t)
+		case float64:
+			out[k] = strconv.FormatInt(int64(t), 10)
+		case []any:
+			parts := make([]string, 0, len(t))
+			for _, item := range t {
+				parts = append(parts, fmt.Sprint(item))
+			}
+			out[k] = strings.Join(parts, arrayConnector)
+		default:
+			out[k] = fmt.Sprint(val)
+		}
+	}
+	return out, nil
+}