收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -1099,7 +1099,7 @@ cash_available = balance - frozen_balance
后续管理并行读取现有套餐列表和 `GET /api/admin/shop-series-grants/{id}`,由前端按 `package_id` 合并:已授权项置灰,未授权项可多选,存量已授权但当前不再可售/可见的项目仍通过授权详情只读展示。
保留 `PUT /api/admin/shop-series-grants/{id}/packages`,但请求必须用 `operation_type=authorize|update_cost|remove` 明确表达一种批量命令,单次最多 100 个套餐、事务内全成全败。新增授权同价重复幂等,不同价重复冲突;调价和移除不能伪装成新增授权。前后端同批切换,不保留旧的混合新增/改价/移除语义
保留 `PUT /api/admin/shop-series-grants/{id}/packages` 及已交付的 `packages:[{package_id,cost_price,remove}]` 契约:`remove=true` 表示软删除,未设置或为 `false` 时按当前授权状态新增或改价。单次最多 100 个套餐、事务内全成全败;不得删除 `remove` 字段、要求必填顶层 `operation_type` 或改变既有响应结构
页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰。

View File

@@ -1019,7 +1019,7 @@
接口约定:
- 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grantsPOST必须同时提交至少一个packages项。
- 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。
- PUT /api/admin/shop-series-grants/{id}/packagesbody={operation_type:authorize|update_cost|remove,packages:[{package_id,cost_price?}]}
- PUT /api/admin/shop-series-grants/{id}/packagesbody={packages:[{package_id,cost_price?,remove?}]}`remove=true` 表示软删除,必须保持既有字段兼容
交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。
@@ -1037,7 +1037,7 @@
**描述**
```markdown
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口,并把新增、调价、移除拆成明确命令不新增候选Query。
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口并保留 `packages[].remove` 已交付契约不新增候选Query。
预计工时后端0.51小时。
@@ -1045,11 +1045,11 @@
首次授权POST中的packages改为必填且至少1项系列与套餐在同一事务全成全败不允许空系列授权。
后续规则PUT必须提交operation_type=authorize|update_cost|remove一次只执行一种命令、最多100项、事务内全成全败authorize同价重复幂等、不同价重复冲突不能静默改价;所有系列、套餐、直属下级与价格边界由后端校验。
后续规则PUT继续提交packages数组`remove=true` 表示软删除其他项按当前授权状态新增或改价最多100项、事务内全成全败。不得要求必填operation_type或改变既有响应结构;所有系列、套餐、直属下级与价格边界由后端校验。
读取:前端组合现有套餐列表和授权详情;已授权但当前不在普通列表中的存量项仍从详情只读展示。成本字段按操作者视角准确命名。
完成标准:首次授权不会产生空系列;新增、调价和移除无语义混用;重复提交不生成重复关系或静默覆盖并发价格;前后端同批切换
完成标准:首次授权不会产生空系列;`packages[].remove` 兼容保留;重复提交不生成重复关系;旧前端无需切换新命令字段即可继续工作
```
## UR#42 导出功能

View File

@@ -0,0 +1,792 @@
//go:build ignore
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
ranNumLib "math/rand"
"github.com/google/uuid"
)
// XML请求体结构
type ContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
Type string `xml:"TYPE"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
StatusInfo string `xml:"STATUSINFO"`
AccNbr string `xml:"ACCNBR"`
ICCID string `xml:"ICCID"`
SendDt string `xml:"SENDDT"`
AcceptType string `xml:"ACCEPTTYPE"`
AcceptMsg string `xml:"ACCEPTMSG"`
StatusDt string `xml:"STATUSDT"`
ResultMsg string `xml:"RESULTMSG"`
}
// 第三方推送数据结构
type PushData struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
RequestID string `json:"requestId"`
RealStatus bool `json:"realStatus"`
ICCID string `json:"iccid"`
} `json:"data"`
}
func parseCallback(jsonStr []byte) (string, string, error) {
// 定义匿名结构体用于解析外层JSON
var cb struct {
Data string `json:"data"`
}
err := json.Unmarshal(jsonStr, &cb)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
}
// 定义匿名结构体用于解析内层数据
var inner struct {
DateChanged string `json:"dateChanged"`
ICCID string `json:"iccid"`
}
err = json.Unmarshal([]byte(cb.Data), &inner)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
}
return inner.ICCID, inner.DateChanged, nil
}
// 5GCMP实名后推送到第三方平台
func pushToThirdParty(iccid string) (string, error) {
// 构建推送数据
pushData := PushData{
Msg: "查询成功",
Code: 200,
}
pushData.Data.RequestID = uuid.New().String()
pushData.Data.RealStatus = true
pushData.Data.ICCID = iccid
// 序列化为JSON
jsonData, err := json.Marshal(pushData)
if err != nil {
return "", fmt.Errorf("序列化推送数据失败: %v", err)
}
// 发送HTTP POST请求
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
return string(respBody), nil
}
// 获取日志文件路径
func getLogFilePath() string {
// 创建logs目录
logsDir := "logs"
if err := os.MkdirAll(logsDir, 0755); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
// 按日期生成文件名
dateStr := time.Now().Format("2006-01-02")
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
return filepath.Join(logsDir, fileName)
}
func GetQcRandNum() (ranNum string) {
randomFloat := ranNumLib.Float64()
if randomFloat < 0.5 {
randomFloat = 1 - randomFloat
}
ranNum = fmt.Sprintf("%.16f", randomFloat)
return
}
// 写入日志
func writeLog(content string) {
logFile := getLogFilePath()
// 打开或创建日志文件
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Printf("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 写入日志内容
if _, err := file.WriteString(content); err != nil {
log.Printf("写入日志失败: %v", err)
}
}
// 向管理平台发送删除实名请求
func DelRealName(iccid string) (res string) {
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
realnameId := getRealnameIdByIccid(iccid, sessionid)
if realnameId == "" {
return
}
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return ""
}
client := &http.Client{
Timeout: 10 * time.Second,
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
log.Printf("删除实名响应: %s", respBody)
res = string(respBody)
return
}
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
PageNo int `json:"pageNo"`
PageCount int `json:"pageCount"`
PageSize int `json:"pageSize"`
PageStartOffset int `json:"pageStartOffset"`
Total int `json:"total"`
Rows []struct {
MybatisRecordCount int `json:"mybatisRecordCount"`
OrderNo string `json:"orderNo"`
JSONUpdateFlag string `json:"jsonUpdateFlag"`
ID string `json:"id"`
IccidMark string `json:"iccidMark"`
Phone string `json:"phone"`
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status int `json:"status"`
CreateName string `json:"createName"`
CreateDate string `json:"createDate"`
StatusStr string `json:"statusStr"`
} `json:"rows"`
Framework string `json:"framework"`
Data string `json:"data"`
Count int `json:"count"`
Limit int `json:"limit"`
Page int `json:"page"`
Layui bool `json:"layui"`
} `json:"data"`
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
realnameId = JSONData.Data.Rows[0].ID
}
log.Printf("响应体: %s", respBody)
return
}
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&phone=%s", msisdn)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
Rows []struct {
IccidMark string `json:"iccidMark"`
} `json:"rows"`
Count int `json:"count"`
} `json:"data"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
iccid = JSONData.Data.Rows[0].IccidMark
}
log.Printf("响应体: %s", respBody)
return
}
func ModifyDate(iccid, dateChanged string) {
var jsonData = map[string]interface{}{
"iccid": iccid,
"dateChanged": dateChanged,
}
jsonDataBs, _ := json.Marshal(jsonData)
// 创建请求
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
if err != nil {
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 设置Content-Type为x-www-form-urlencoded
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
defer resp.Body.Close()
// 读取响应内容
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 检查响应状态
if resp.StatusCode != http.StatusOK {
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
}
// 处理回调请求
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
// 处理XML请求体和第三方推送
var pushResponse string
log.Printf("body:%s\r\n", string(body))
if len(body) > 0 {
// 尝试解析XML
var contractRoot ContractRoot
if err := xml.Unmarshal(body, &contractRoot); err == nil {
logBuilder.WriteString("--- XML解析结果 ---\n")
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
// 如果TYPE=1表示实名认证成功需要推送到第三方
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(contractRoot.ICCID, timestampStr)
// 执行推送
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
pushResponse = fmt.Sprintf("推送失败: %v", err)
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
pushResponse = resp
}
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
res := DelRealName(contractRoot.ICCID)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
} else {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
}
} else {
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
}
}
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回成功响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
// 如果有推送响应,添加到响应中
if pushResponse != "" {
responseData["pushResponse"] = pushResponse
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 获取管理平台登录凭证
func (ac Account) Login() (sessionid string) {
var password string
password = ac.Password
var requestBody bytes.Buffer
multipartWriter := multipart.NewWriter(&requestBody)
multipartWriter.WriteField("username", ac.UserName)
multipartWriter.WriteField("password", password)
multipartWriter.Close()
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
//client1 := http.DefaultClient
client1 := &http.Client{
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
strs := strings.Split(req1.URL.Path, ";")
log.Printf("%s\r\n", req1.URL.Path)
if len(strs) == 2 {
strs1 := strings.Split(strs[1], "=")
if len(strs1) == 2 {
sessionid = strs1[1]
}
}
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
return nil
},
}
resp, err := client1.Do(req)
if err != nil {
fmt.Println("Failed to send request:", err)
return
}
defer resp.Body.Close()
// 处理响应
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("Failed to read response:", err)
return
}
//fmt.Println("Response:", string(respBody))
return
}
// 移动实名回调
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【移动实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
var JSONData struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
Msisdn string `json:"msisdn"`
Iccid string `json:"iccid"`
} `json:"result"`
}
json.Unmarshal(body, &JSONData)
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
iccid := JSONData.Result[0].Iccid
if iccid == "" {
msisdn := JSONData.Result[0].Msisdn //接入号
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
iccid = getIccidByMsisdn(msisdn, sessionid)
}
if iccid == "" {
return
}
//推送修改过期时间
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(iccid, timestampStr)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
// 执行推送实名状态
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
logBuilder.WriteString("--- 第三方推送 ---\n")
} else {
logBuilder.WriteString("(实名认证失败)\n")
}
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回200 OK响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 联通实名回调
func UniRealnameCallback(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【联通实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
defer r.Body.Close()
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
if len(iccid) == 20 {
iccid = iccid[:19]
}
if iccid != "" {
ModifyDate(iccid, dateChanged)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
}
}
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
writeLog(logBuilder.String())
fmt.Print(logBuilder.String())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
respJSON, _ := json.Marshal(map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
})
w.Write(respJSON)
}
// 联通解除实名回调
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 解析回调数据
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
if len(iccid) == 20 {
//取前面19位
iccid = iccid[:19]
//删除实名
res := DelRealName(iccid)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
}
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
}
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
func main() {
// 注册路由
// res := DelRealName("8986112422108176397")
// log.Printf("删除实名响应: %s", res)
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
http.HandleFunc("/unicom/callback/realname", UniRealnameCallback)
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
// 启动服务器
port := ":16159"
fmt.Printf("5GCMP回调服务器启动成功\n")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
fmt.Printf("联通实名回调地址: %s/unicom/callback/realname\n", port)
fmt.Printf("联通解除实名回调地址: %s/unicom/callback/realname/remove\n", port)
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
fmt.Printf("日志目录: logs/\n")
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
}
type Account struct {
UserName string
Password string
}

View File

@@ -719,11 +719,3 @@ type Account struct {
}
```
优化轮询以及添加事件/触发式 数据同步
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了

View File

@@ -3939,18 +3939,25 @@ components:
nullable: true
type: integer
packages:
description: 初始授权套餐列表
description: 初始授权套餐列表1100项套餐ID不可重复
items:
$ref: '#/components/schemas/DtoGrantPackageItem'
maxItems: 100
minItems: 1
nullable: true
type: array
series_id:
description: 套餐系列ID
minimum: 0
minimum: 1
type: integer
shop_id:
description: 被授权代理店铺ID
minimum: 0
minimum: 1
type: integer
required:
- shop_id
- series_id
- packages
type: object
DtoCreateWechatConfigRequest:
properties:
@@ -5724,16 +5731,20 @@ components:
DtoGrantPackageItem:
properties:
cost_price:
description: 成本价(分)
description: 成本价(分),新增或调价时必填
minimum: 0
nullable: true
type: integer
package_id:
description: 套餐ID
minimum: 0
minimum: 1
type: integer
remove:
description: 是否删除该套餐授权true=删除)
nullable: true
type: boolean
required:
- package_id
type: object
DtoImportDeviceRequest:
properties:
@@ -6239,11 +6250,15 @@ components:
nullable: true
type: string
packages:
description: 套餐操作列表
description: 套餐操作列表1100项套餐ID不可重复
items:
$ref: '#/components/schemas/DtoGrantPackageItem'
maxItems: 100
minItems: 1
nullable: true
type: array
required:
- packages
type: object
DtoManualTriggerLogListResp:
properties:
@@ -29216,6 +29231,82 @@ paths:
summary: 支付宝回调
tags:
- 支付回调
/api/callback/carriers/cmcc/realname:
post:
description: 接收中国移动 JSON 实名成功结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 移动实名结果回调
tags:
- 运营商回调
/api/callback/carriers/ctcc/realname:
post:
description: 接收电信 XML 实名补录结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 电信实名结果回调
tags:
- 运营商回调
/api/callback/carriers/cucc/realname:
post:
description: 接收中国联通嵌套 JSON 实名成功结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 联通实名结果回调
tags:
- 运营商回调
/api/callback/carriers/cucc/realname/remove:
post:
description: 识别中国联通解除实名 JSON仅留痕且不修改本地实名事实。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 联通解除实名回调
tags:
- 运营商回调
/api/callback/fuiou-pay:
post:
responses:

View File

@@ -44,6 +44,6 @@
## 现在不需要调整的内容
`GET /api/admin/system-configs``PUT /api/admin/system-configs/{key}` 当前只是公共受控配置接缝不代表前端现在要新增系统配置页面。UR#48 注册正式支付配置 Key、全局审计正式 Adapter 接入并通过发布门禁后,再按 UR#48 的页面范围联调
`GET /api/admin/system-configs``PUT /api/admin/system-configs/{key}` 是超级管理员受控配置接口。业务模块注册正式 Key 后即可按 `control` 渲染并更新;全局 Audit Event 已取消,不再作为接口可用前置。是否建设独立系统配置页面仍由实际业务需求决定
Outbox 监控和人工恢复也不在本次前端范围内;公共基础只提供后端运维接缝,未来如需运营页面必须另行评审。

View File

@@ -4,7 +4,7 @@
本能力提供公共 Outbox 与 Relay、创建命令幂等原语、统一异步任务五态、受控系统配置、数据库发布门禁以及 Access Log 递归脱敏和敏感路由安全摘要。公共基础只定义稳定接缝和基础设施语义,不拥有 Audit Event、Integration Log、站内通知、业务模型、业务状态机、业务唯一键、任务失败明细或业务事件消费者。
各业务 PRD 必须自行实现消费者副作用幂等,并决定使用事件 ID、业务唯一键、状态条件更新或版本号裁决重复消费。审计和通知由对应公共能力实现后注入现有 Port系统配置更新在审计 Port 不可用时失败关闭,不另建临时审计表
各业务 PRD 必须自行实现消费者副作用幂等,并决定使用事件 ID、业务唯一键、状态条件更新或版本号裁决重复消费。通知由对应公共能力实现后注入现有 Port全局 Audit Event 已按七月总 Change 决策取消,系统配置在审计 Port 未装配时仍可由超级管理员更新已注册 Key不另建临时审计表或借用 Integration Log
## 关键流程
@@ -62,7 +62,7 @@ Access Log 对 query、请求 JSON 和响应 JSON 复用大小写不敏感的递
## 已登记的后续闭环项
- 系统配置更新的正式审计 Adapter 由[全局审计 20 号票](../../.scratch/tech-global-audit/issues/20-system-config-audit-adapter.md)接入Outbox 人工恢复由[全局审计 21 号票](../../.scratch/tech-global-audit/issues/21-outbox-recovery-audit-adapter.md)接入,并统一由[全局审计 19 号发布门禁](../../.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md)验证;不再作为无负责人的待决策项
- 全局 Audit Event 及其系统配置 Adapter 已移出七月总 Change不再阻塞受控配置更新。若未来重新立项可通过现有可选 Audit Writer 在同一事务写入;当前不得用 Access Log 或 Integration Log 冒充配置审计
- 各下游 PRD 必须在自己的最终发布门禁中确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略,并引用具体公共票;[INT-08](../7月迭代/7月迭代禅道研发需求逐条录入稿.md#int-08-全链路与停机发布验收) 汇总检查,公共基础仍不猜测业务事件定义。
- Outbox 待投递年龄、积压量、过期租约比例和成功率的生产告警值由发布负责人依据容量基线填写到 INT-08 发布清单。没有数值、负责人和验证记录时不得放量。

View File

@@ -0,0 +1,72 @@
# UR#43 代理系列套餐批量授权功能总结
## 完成范围
本次复用既有系列授权、套餐授权模型和四个现有接口,不新增候选套餐 API、不新增迁移、不改整店批量调价逻辑
- `GET /api/admin/packages`:按 `series_id` 获取当前操作者可见套餐。
- `POST /api/admin/shop-series-grants`:首次创建系列授权,并原子创建 1100 条套餐授权。
- `GET /api/admin/shop-series-grants/{id}`:读取目标代理已授权套餐及其当前成本价。
- `PUT /api/admin/shop-series-grants/{id}/packages`:通过既有套餐项字段批量新增、修改成本价或软删除授权。
当前仓库不包含前端源码。本次交付框架无关的页面组合与交互契约,前端实现和真实人工验收仍需在对应仓库完成。
## 首次批量授权
创建系列授权时,`packages` 必填且只能包含 1100 项;每项必须提供唯一 `package_id` 和非负 `cost_price`。后端在同一 PostgreSQL 事务内锁定并校验目标店铺、上级有效系列授权、套餐和上级有效套餐授权,全部通过后才写系列授权、套餐授权和价格历史。
- 代理只能授权当前直属下级店铺。
- 套餐必须未删除、属于目标系列且不是赠送套餐;禁用或下架不等于不能预先配置授权。
- 代理必须拥有对应的有效上级套餐授权;目标成本价不得低于当前上级成本价。
- 任一项失败时,系列授权、套餐授权和价格历史全部回滚,不会留下空系列授权。
- `expiry_base_override` 继续遵守 UR#55 契约,必须显式传 `null` 或合法枚举。
## 后续兼容契约
`PUT /api/admin/shop-series-grants/{id}/packages` 继续接受已交付的 `packages[].remove` 字段:
```json
{
"expiry_base_override": null,
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000},
{"package_id": 1003, "remove": true}
]
}
```
| 套餐项 | 成本价字段 | 业务语义 |
| --- | --- | --- |
| `remove=true` | 可省略 | 软删除当前授权;已不存在按幂等成功 |
| `remove` 缺失或为 `false`,当前未授权 | 必填,允许 0 | 新增套餐授权 |
| `remove` 缺失或为 `false`,当前已授权 | 必填,允许 0 | 按现有规则更新成本价 |
请求要求 1100 个套餐项,并在一个事务内完成权限校验、价格边界和写入。`remove` 是 2026-03-04 已交付字段,不得删除、改名或替换为必填顶层 `operation_type`
成功响应继续返回刷新后的 `ShopSeriesGrantResponse`,保持旧前端无需适配新的响应结构。
## 前端读取与价格语义
首次授权只读取 `GET /api/admin/packages?series_id={series_id}`;后续管理并行读取套餐列表和授权详情,再按 `package_id` 合并:
1. 套餐列表中的 `cost_price` 表示当前上级成本价。平台视角是公司成本价,代理视角是该代理自己的授权成本价。
2. 授权详情 `packages[].cost_price` 表示目标代理当前授权成本价。
3. 套餐列表 `suggested_retail_price` 表示建议零售价。
4. 详情存在的套餐标记 `is_authorized=true`;只在列表出现的套餐标记 `is_authorized=false`,未授权成本显示“-”,不能用 0 代替。
5. 已授权但因上级权限变化不再出现在普通列表的存量项,仍从详情只读展示,不能静默消失。
页面可以提供授权、调价、移除三种交互模式。新增模式只允许选择未授权项;调价和移除模式只允许选择已授权项;提交移除时设置对应套餐项的 `remove=true`。提交期间禁止重复提交。
## 权限、可靠性与范围裁决
- 超级管理员和平台账号沿用现有管理范围;代理必须仍是目标店铺直属上级,不能仅凭历史 `allocator_shop_id` 操作已转移店铺。
- 资源不存在与越权统一返回安全错误,不暴露其他代理授权或成本价。
- 价格历史与授权写入同事务Audit Event 已由七月总 Change 明确移出本次范围,不接入 Audit Writer也不以 Integration Log 冒充业务审计。
- 本任务不修改订单、客户套餐使用、零售价、佣金、强充或整店批量调价,不自动级联回收下级授权。
## 发布与回滚
后端发布必须兼容旧前端,不要求切换新命令字段。发布前应确认 OpenAPI 仍包含 `packages[].remove`,并核对同一店铺/套餐有效授权重复、孤立下级授权和异常价格;本功能没有新增迁移。
回滚应用时保留新版本已经产生的系列授权、套餐授权和价格历史,不清表、不物理删除业务事实。自动化测试与真实 PostgreSQL 并发验收按七月总 Change 的 6.1/6.3 延期本批只执行格式化、OpenAPI 生成、构建和静态部署检查。

View File

@@ -2,7 +2,87 @@
## 当前完成范围
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.342.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环三个轮询入口切换观测序列和运营商回调仍按 2.382.45 继续实施,不能因当前能力已落地而提前开放回调入口
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.342.44A 已交付实名、流量、网络观测公共写入闭环三个轮询入口切换、通用观测序列、业务入口触发以及电信实名、移动实名、联通实名成功和联通解除实名四个回调防腐层。UR#94 整体发布检查仍按 2.45 继续实施,不能因四条路由已接入而提前宣称整体生产验收完成
## 联通实名成功回调
新增来源材料 `docs/7月迭代/来源材料/realname.go` 补齐了此前缺失的联通实名成功报文,因此任务 2.44A 新增 `POST /api/callback/carriers/cucc/realname`。协议与联通解除实名一致:外层 `data` 必须是字符串,再解析内层非空 `iccid/dateChanged`
- 合法 ICCID 只按 19/20 位对应列精确查询;不复制旧代码的 20 位截 19 位,不跨列降级,也不任取多匹配卡。
- 使用 `ICCID + dateChanged` 的安全摘要作为语义幂等键;解析失败使用正文摘要,重复、冲突和中断恢复沿用 Integration Log 租约规则。
- 唯一命中后调用公共 `ApplyCardObservation(verified=true)`,实名事实变化时由公共用例写 Outbox并尽力提前完成同卡实名观测序列。
- 不调用旧 `inner_callback`、第三方推送、旧平台登录、`ModifyDate` 或 Gateway 二次确认;`dateChanged` 只作为上游变更时间和幂等语义留痕,不直接改写本地业务时间。
- 无论开关状态、报文结果或内部处理结果,均返回入口时间对应的 HTTP 200 固定 JSON 应答,避免运营商不可控重推。
## 运营商回调独立开关
四条回调复用公共 `system_config`,模块名为 `carrier_callback`,默认全部关闭:
- `carrier_callback.ctcc_realname.enabled`
- `carrier_callback.cmcc_realname.enabled`
- `carrier_callback.cucc_realname.enabled`
- `carrier_callback.cucc_realname_removal.enabled`
路由始终保留。开关关闭时不解析报文、不定位卡、不调用公共观测,只按正文摘要幂等记录 `ignored` Integration Log 并返回固定成功应答;配置缺失、非法或读取失败时失败关闭,同样不执行业务写入。配置读取以 PostgreSQL 为事实来源并复用 Redis 五分钟缓存。
超级管理员可通过 `GET /api/admin/system-configs?module=carrier_callback` 查询四个已注册开关,并通过 `PUT /api/admin/system-configs/:key` 更新布尔值。全局 Audit Event 已按用户决策取消Audit Writer 改为可选;若未来装配 Writer审计仍与配置更新同事务执行未装配时不会再阻塞受控配置更新也不会用 Integration Log 冒充配置审计。
## 联通解除实名留痕回调
任务 2.44 新增 `POST /api/callback/carriers/cucc/realname/remove`。Adapter 强制要求外层 `data` 为 JSON 字符串,再解析内层 `iccid` 与非空 `dateChanged`;任何情况下都返回入口时间对应的固定 JSON 成功应答。
- 空正文、外层或内层 JSON 失败、`data` 非字符串、ICCID 非法及变更时间为空统一记录 `invalid_payload`
- 合法 ICCID 仅按 19/20 位对应列精确定位;未找到、多匹配和数据库错误分别记录 `not_found``conflict``failed`,不会跨列降级或任取卡。
- 合法且唯一识别的解除通知统一终结为 `ignored`;不调用 `ApplyCardObservation`、观测序列、Gateway、旧 `DelRealName` 或第三方推送,不修改实名状态、首次实名时间、检查时间和逆转计数。
- 解析成功时使用 `ICCID + dateChanged` 语义摘要防重JSON 空白或字段顺序变化保持同一幂等语义;同语义不同正文另记 `conflict`解析失败则以完整正文摘要防重pending 记录支持租约恢复。
- Access Log 继续使用运营商回调摘要策略Integration Log 只保存正文长度、哈希、Content-Type 和哈希资源键;未新增数据库迁移或自动化测试。
## 移动实名成功回调防腐层
任务 2.43 新增 `POST /api/callback/carriers/cmcc/realname`,只解析已知 JSON 结构,并使用请求入口捕获时间返回 `code=200``msg=success``YYYY-MM-DD HH:mm:ss` 格式时间戳。
- 只有 `status="0"``message="正确"`、首条 `result.regStatus="00000"` 且 ICCID 合法时生成已实名观测;空 ICCID、失败状态、空结果和无法解析的 JSON 均记录 `invalid_payload`
- 优先使用 `busiSeq` 的安全摘要作为稳定幂等语义,缺失时使用正文摘要;同事务不同正文另记 `conflict`pending 处理超过一分钟后允许原子认领恢复。
- ICCID 仅按 19/20 位精确列定位,不按 MSISDN 补查,不登录旧管理平台,不保存账号密码或 Cookie也不调用旧第三方推送、修改到期时间或 Gateway 二次确认。
- 成功结果复用公共 `ApplyCardObservation` 并尽力提前完成同卡实名序列;未找到、多匹配和内部错误分别终结为 `not_found``conflict``failed`,不会任取卡写入。
- Access Log 复用运营商回调正文摘要策略Integration Log 不保存完整 JSON 或明文 ICCID仅保存长度、摘要、Content-Type 和哈希资源键。
- 新 Handler 已接入生产组合根、真实路由、OpenAPI Handler 构造和两个文档生成器。本任务按测试延期约定未新增或运行自动化测试。
## 电信实名回调防腐层
任务 2.42 新增 `POST /api/callback/carriers/ctcc/realname`,按已知 `ContractRoot` XML 协议解析电信实名结果,并始终返回运营商约定的 JSON 成功应答。
- 只有 `RESULTMSG=成功``ACCEPTMSG` 包含“已完成实名信息补录”时生成已实名观测;实名信息清除和其他合法业务结果仅记录 `ignored`,不查询或修改卡。
- ICCID 去除首尾空白后复用公共格式校验,仅接受 19 或 20 位值,并分别精确查询 `iccid_19``iccid_20`;查询不应用登录账号数据范围,未找到和多匹配分别记录 `not_found``conflict`
- 入站先以 `GROUP_TRANSACTIONID` 的安全摘要建立稳定幂等语义;同一外部事务的重复载荷直接成功返回,不重复首次实名、套餐激活或领域事件,不同载荷另记 `conflict`。缺少外部事务号时以完整正文摘要防重;若首次处理在 `pending` 阶段中断,超过一分钟租约后的重复回调可原子认领并恢复处理。
- 实名成功复用公共 `ApplyCardObservation` 事务闭环,并在成功后尽力提前完成同卡未执行实名观测序列;内部处理失败尽力把 Integration Log 终结为 `failed`
- Access Log 对 `/api/callback/carriers/` 强制使用敏感正文摘要策略,不记录完整 XML 或明文 ICCIDIntegration Log 只保存正文长度、SHA-256 摘要和哈希资源键。
- 迁移 `000181` 为 Integration Log 增加 `conflict` 终态发布时必须先执行迁移再开放路由down 迁移会先把已有 `conflict` 归并为 `failed`
- 新 Handler 已接入真实路由、生产组合根、OpenAPI Handler 构造以及 `cmd/api/docs.go``cmd/gendocs/main.go` 两个文档生成器。
## 读取与实名入口的 Best Effort 观测触发
任务 2.39 在不迁移 Query、不新增接口、不改变响应结构的前提下接入了 C 端资产详情、后台资产实时状态、OpenAPI 卡/设备流量、OpenAPI 卡网络/实名查询,以及 C 端和后台实名链接入口。
- 读取型入口使用稳定且调用方无关的场景码,按实际卡资源和同步类型创建无预期序列;同一卡连续 OpenAPI 查询通过 2.38 的场景合并键复用未结束序列,不会按账号或每次请求追加三任务。
- C 端和后台设备资产详情按绑定卡分别触发实名、流量、网络观测OpenAPI 设备流量同样按绑定卡触发流量观测,卡标识解析到设备时仍以实际绑定卡为资源。
- C 端实名链接和后台 Gateway 实名链接仅在运营商 `realname_link_type != none` 且原有链接响应成功后触发 `expected=verified` 的实名序列;不支持在线实名的运营商不会建序列。
- 读取入口只把结构化请求放入容量受控的进程内分发队列,不等待 Redis、Asynq 或 Gateway也不把后台序列结果写入当前响应队列满载或 Redis/Asynq/Integration Log 失败只输出中文安全日志并保留 scene、resource、sync、series 和 request_id 关联信息。
- 原有后台与 C 端 `refresh` 手动刷新路径未注入分发器,仍直接执行一次同步,不生成 0/3/5 序列。OpenAPI、资产查询和实名入口的现有错误码、响应字段与业务结果保持不变。
## 0/3/5 卡观测事件序列
任务 2.38 已交付内部 `SeriesTrigger`、固定阶梯 Asynq 调度器和 Worker 执行闭环,但未修改任何查询、停复机、购包、套餐或设备控制入口;入口接入仍由 2.392.41 分别完成。
- 每个新序列以稳定 `series_id` 创建立即、3 分钟、5 分钟三个结构化任务,任务 ID 使用 `series_id + attempt``MaxRetry(0)`,单次失败不会删除或取消后续两个独立任务。
- Redis 合并键严格包含 `scene + resource_type + resource_id + sync_type`,只覆盖最后一次计划任务和短暂缓冲。首触发的预期、来源、请求/关联 ID 与基准时间被原子保存;重复触发不刷新上下文或 TTL补齐首次局部入队失败时仍使用首触发上下文同时避免重复生成第二组三任务。缺失请求/关联 ID 时生成同一稳定 UUID 贯穿三任务。
- 尝试执行前读取本地权威快照。明确预期已满足时,当前及剩余尝试立即写 `completed` Integration Log 并标记幂等完成,不再访问 Gateway`CompleteResourceSeries` 为可信实名回调提前结束同卡实名序列提供扩展点。
- 实际 Gateway 请求使用 `provider + sync_type + resource_id` Redis 互斥16 分钟 TTL 覆盖配置允许的 300 秒单次超时、两次网络重试和安全余量;流量同步复用既有 `traffic:sync:lock:card:{id}` 卡级锁,避免事件、轮询和手动刷新并发读取同一上游读数。互斥命中只把当前尝试记录为 `ignored`,后续阶梯任务保持不变。
- 运营商接入与同步类型的默认最小请求间隔为 10 秒。任务在持有本次请求互斥期间只等待剩余间隔,不建立全局五分钟冷却,也不创建 30/60/120 秒退避Gateway 超频只把当前 Integration Log 终结为 `rate_limited`
- 实名、流量、网络实际响应全部复用 `ApplyCardObservation``ApplyTrafficObservation``ApplyNetworkObservation`,没有新增第二套状态写入。设备信息同步类型已在 Application 契约中预留,具体执行适配器随 2.41 交付。
- 合并、互斥、最小间隔取消、预期提前完成和每次实际 Gateway 请求统一写 `tb_integration_log`,传播请求 ID、关联 ID、序列 ID、尝试序号、场景、资源、结果、耗时和是否变化请求摘要只保存卡 ID 与同步类型,不保存完整 ICCID。
本任务按 Change 测试延期约定未新增自动化测试,也未新增数据库迁移或同步运行表。序列运行态、幂等键和短时互斥属于 Redis 协调事实,三次可查询结果继续使用公共 Integration Log。
## 流量观测公共写入闭环
@@ -108,7 +188,9 @@ ORDER BY id;
## 回滚
down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭回调入口并确认没有并发写入,再评估回滚。
优先通过四个 `system_config` 开关分别停止业务处理,路由继续固定成功应答。down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭相关开关并确认没有并发写入,再评估回滚。
迁移顺序固定为 `000178`ICCID 唯一性)→ `000179`(实名逆转窗口)→ `000180`(副作用进度)→ `000181`Integration Log conflict 终态。一旦已经产生卡状态、Outbox、Integration Log 或副作用进度事实,不清表、不删除业务事实,也不恢复旧 Writer暂停对应开关和生产者后采用前向修复。
## 验证状态
@@ -116,4 +198,12 @@ down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。
- 已执行 `gofmt``go build ./...`;构建退出码为 0。
- 已静态核对观测序列任务固定为 0/3/5 分钟、`MaxRetry(0)`、结构化载荷、同场景合并键、`series_id + attempt` 幂等键、流量共享锁和 10 秒默认最小间隔;任务 2.38 未修改业务入口。
- 已静态核对任务 2.39 只接入 issue 07 指定读取与实名链接入口,稳定 OpenAPI 场景不包含账号身份,`realname_link_type=none` 不触发,后台/C 端手动刷新路径没有分发调用。
- 已静态核对任务 2.42 的 XML 根结构、19/20 位精确查询、系统级资源定位、稳定外部事务幂等、载荷冲突留痕、固定成功应答、Access Log 摘要策略及 Handler/路由/文档生成器装配。
- 已静态核对新增联通实名成功回调的双层 JSON、非空变更时间、19/20 位精确查询、语义幂等、公共实名观测、固定成功应答及四个受控系统配置开关。
- 任务 2.45 已完成四个回调 Handler、免认证路由、生产组合根、两个文档生成器、OpenAPI 产物、观测 Worker、Outbox Consumer、API/Worker Gateway Client 和迁移顺序的静态装配检查。
- `docs/admin-openapi.yaml` 已生成电信实名、移动实名、联通实名成功和联通解除实名四条路径;四个开关均通过后台受控 `system_config` 注册,默认关闭。
- 已执行 `gofmt``git diff --check` 和离线 `go build ./...`,构建退出码为 0。按用户要求保留既有 Gateway 与部署 YAML 配置,本任务未修改其配置来源或部署值。
- 当前状态只能标记为“后端代码与测试环境装配完成、真实验证延期”;真实运营商样例、真实 Gateway、19/20 位命中、逐个启停和异常恢复统一转 6.4。
- 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。