Files
zeekay e285e18128 feat: bootstrap notifyd service layer on hanzoai/base + hanzoai/tasks
Replaces the chi-based notifyd scaffold with the canonical
hanzoai/auto-style layout: hanzoai/base as the HTTP + SQLite + auth
substrate (via the platform plugin for IAM JWT validation and
X-Org-Id injection), hanzoai/tasks for durable async sends, and a
per-tenant Notifier resolver that pulls provider credentials from
KMS through platform.KMSClient.

Library at the module root stays untouched — all 33 provider impls
(plivo, twilio, ses, sendgrid, mail, whatsapp, discord, slack, …)
keep their existing public API. The service wraps them.

cmd/notifyd
  - base.New + platform.MustRegister
  - schema.MustRegister (6 collections: tenants, providers,
    templates, events, messages, meter)
  - tasks worker registered when TASKS_ADDR is set; sync path
    runs the same Deliver activity directly when not

internal/
  - boot/      thin platform.KMSClient wiring (env-driven)
  - schema/    6 base collections
  - routes/    /v1/notify/{send,messages,providers,templates,events,metering,health}
               + convenience /send/{sms,email,voice,whatsapp}
  - tenant/    KMS-backed resolver builds library Notifier per send
  - template/  draft → pending → approved → published → archived
  - event/     catalog routing (fills missing channel / template)
  - metering/  per-send ledger writer + aggregate query
  - tasks/     NotifySendWorkflow + Deliver activity (idempotent on
               replay; reloads message row before resending)
  - zaprpc/    procedure name constants (server impl follow-up)

pkg/
  - client/    public HTTP SDK (Send / Message / Providers / Templates)
  - types/     wire shapes shared by routes and client

cucloud: fmt.Errorf(msg) → errors.New(msg) for the go 1.26 vet pass
that landed with this dependency bump. Behaviour unchanged.

Boot smoke-tested locally:
  - SQLite migrations apply cleanly
  - /v1/notify/health returns {"service":"notify","status":"ok"}
  - missing X-Org-Id returns 401 on every authenticated route
  - send with stub tenant returns the expected base validation error
2026-06-01 16:06:27 -07:00

160 lines
3.6 KiB
Go

// Copyright 2025 The Hanzo IAM Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cucloud
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"golang.org/x/exp/maps"
)
type CuCloud struct {
AccessKey string
SecretKey string
TopicName string
MessageTitle string
CloudRegionCode string
AccountId string
NotifyType string
Client http.Client
}
type CuCloudResp struct {
Code int `json:"code"`
Message string `json:"message"`
Result string `json:"result"`
}
func New(accessKey, secretKey, topicName, messageTitle, cloudRegionCode, accountId, notifyType string) *CuCloud {
return &CuCloud{
accessKey,
secretKey,
topicName,
messageTitle,
cloudRegionCode,
accountId,
notifyType,
http.Client{},
}
}
func (c *CuCloud) Send(ctx context.Context, subject, content string) error {
timeNow := time.Now().UnixMilli()
reqHeader := map[string]string{}
reqHeader["algorithm"] = "HmacSHA256"
reqHeader["requestTime"] = strconv.FormatInt(timeNow, 10)
reqHeader["accessKey"] = c.AccessKey
reqBody := map[string]string{}
reqBody["notifyType"] = c.NotifyType
reqBody["messageTitle"] = url.QueryEscape(c.MessageTitle)
reqBody["messageType"] = "text"
reqBody["messageContent"] = url.QueryEscape(content)
reqBody["topicName"] = url.QueryEscape(c.TopicName)
reqBody["messageTag"] = ""
reqBody["templateName"] = ""
reqBody["cloudRegionCode"] = "cn-langfang-2"
signVal, err := c.generateRequestSign(reqHeader, reqBody)
if err != nil {
return err
}
reqHeader["sign"] = signVal
reqHeader["Content-Type"] = "application/json"
reqHeader["Account-Id"] = c.AccountId
reqHeader["User-Id"] = c.AccountId
reqHeader["Region-Code"] = c.CloudRegionCode
bodyJson, err := json.Marshal(reqBody)
if err != nil {
return err
}
req, err := http.NewRequest("POST", "https://gateway.cucloud.cn/smn/SMNService/api/message/notify", bytes.NewReader(bodyJson))
if err != nil {
return err
}
for k, v := range reqHeader {
req.Header.Set(k, v)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBodyRaw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var respBody CuCloudResp
err = json.Unmarshal(respBodyRaw, &respBody)
if err != nil {
return err
}
if respBody.Code != 200 {
return errors.New(respBody.Message)
}
return nil
}
func (c *CuCloud) generateRequestSign(header map[string]string, body map[string]string) (string, error) {
mac := hmac.New(sha256.New, []byte(c.SecretKey))
reqSignMap := make(map[string]string)
maps.Copy(reqSignMap, header)
maps.Copy(reqSignMap, body)
signRawString := ""
var keys []string
for k, _ := range reqSignMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
vJson, err := json.Marshal(reqSignMap[k])
if err != nil {
return "", err
}
signRawString += k + "=" + string(vJson) + "&"
}
signRawString = signRawString[:len(signRawString)-1]
mac.Write([]byte(signRawString))
return hex.EncodeToString(mac.Sum(nil)), nil
}