chore: 并入 iotlcm-onelink-mock 历史到 onelink-mock/(原提交 f398595)
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) adminState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) adminAppIDs(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var cred Credential
|
||||
if err := json.NewDecoder(r.Body).Decode(&cred); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cred.AppID == "" || cred.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "appid and password are required"})
|
||||
return
|
||||
}
|
||||
if cred.Name == "" {
|
||||
cred.Name = cred.AppID
|
||||
}
|
||||
cred.Enabled = true
|
||||
cred.CreatedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
err := s.store.SaveMutation(func(st *Store) error {
|
||||
for i := range st.AppIDs {
|
||||
if st.AppIDs[i].AppID == cred.AppID {
|
||||
st.AppIDs[i].Password = cred.Password
|
||||
st.AppIDs[i].Name = cred.Name
|
||||
st.AppIDs[i].Enabled = cred.Enabled
|
||||
st.AppIDs[i].LastToken = ""
|
||||
st.AppIDs[i].TokenExpires = ""
|
||||
delete(st.Tokens, cred.AppID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
st.AppIDs = append(st.AppIDs, cred)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
case http.MethodDelete:
|
||||
appid := r.URL.Query().Get("appid")
|
||||
_ = s.store.SaveMutation(func(st *Store) error {
|
||||
next := st.AppIDs[:0]
|
||||
for _, c := range st.AppIDs {
|
||||
if c.AppID != appid {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
st.AppIDs = next
|
||||
delete(st.Tokens, appid)
|
||||
return nil
|
||||
})
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminCards(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
page, pageSize := pagination(r)
|
||||
filter, err := cardFilterFromRequest(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.QueryCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup, page, pageSize))
|
||||
case http.MethodPost:
|
||||
var card SIMCard
|
||||
if err := json.NewDecoder(r.Body).Decode(&card); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
normalizeCard(&card)
|
||||
err := s.store.SaveMutation(func(st *Store) error {
|
||||
for i := range st.Cards {
|
||||
if st.Cards[i].MSISDN == card.MSISDN {
|
||||
st.Cards[i] = card
|
||||
return nil
|
||||
}
|
||||
}
|
||||
st.Cards = append(st.Cards, card)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
case http.MethodDelete:
|
||||
msisdn := r.URL.Query().Get("msisdn")
|
||||
_ = s.store.SaveMutation(func(st *Store) error {
|
||||
next := st.Cards[:0]
|
||||
for _, c := range st.Cards {
|
||||
if c.MSISDN != msisdn {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
st.Cards = next
|
||||
return nil
|
||||
})
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminCardGroups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
st := s.store.Snapshot()
|
||||
counts := map[int64]int{}
|
||||
for _, card := range st.Cards {
|
||||
counts[card.GroupID]++
|
||||
}
|
||||
for i := range st.Groups {
|
||||
st.Groups[i].CardCount = counts[st.Groups[i].ID]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st.Groups)
|
||||
case http.MethodPost:
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
if input.Name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group name is required"})
|
||||
return
|
||||
}
|
||||
var created CardGroup
|
||||
err := s.store.SaveMutation(func(st *Store) error {
|
||||
for _, group := range st.Groups {
|
||||
if strings.EqualFold(group.Name, input.Name) {
|
||||
return fmt.Errorf("group name already exists")
|
||||
}
|
||||
}
|
||||
id := int64(1)
|
||||
for _, group := range st.Groups {
|
||||
if group.ID >= id {
|
||||
id = group.ID + 1
|
||||
}
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
created = CardGroup{ID: id, Name: input.Name, CreatedAt: now, UpdatedAt: now}
|
||||
st.Groups = append(st.Groups, created)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, created)
|
||||
case http.MethodDelete:
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid group id"})
|
||||
return
|
||||
}
|
||||
err = s.store.SaveMutation(func(st *Store) error {
|
||||
found := false
|
||||
next := st.Groups[:0]
|
||||
for _, group := range st.Groups {
|
||||
if group.ID == id {
|
||||
found = true
|
||||
} else {
|
||||
next = append(next, group)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("card group not found")
|
||||
}
|
||||
st.Groups = next
|
||||
for i := range st.Cards {
|
||||
if st.Cards[i].GroupID == id {
|
||||
st.Cards[i].GroupID = 0
|
||||
st.Cards[i].GroupName = ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var settings SimulationSettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.store.SaveMutation(func(st *Store) error {
|
||||
st.Settings = settings
|
||||
return nil
|
||||
})
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) adminOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
page, pageSize := pagination(r)
|
||||
writeJSON(w, http.StatusOK, s.store.QueryOrders(page, pageSize))
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_ = s.store.SaveMutation(func(st *Store) error {
|
||||
st.Orders = []Order{}
|
||||
return nil
|
||||
})
|
||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||
}
|
||||
|
||||
func pagination(r *http.Request) (int, int) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
type cardFilter struct {
|
||||
keyword string
|
||||
exportStatus string
|
||||
groupID int64
|
||||
filterGroup bool
|
||||
}
|
||||
|
||||
func cardFilterFromRequest(r *http.Request) (cardFilter, error) {
|
||||
filter := cardFilter{
|
||||
keyword: r.URL.Query().Get("keyword"),
|
||||
exportStatus: strings.TrimSpace(r.URL.Query().Get("exportStatus")),
|
||||
}
|
||||
if filter.exportStatus != "" && filter.exportStatus != "exported" && filter.exportStatus != "not_exported" {
|
||||
return cardFilter{}, fmt.Errorf("invalid export status")
|
||||
}
|
||||
groupRaw := strings.TrimSpace(r.URL.Query().Get("groupId"))
|
||||
if groupRaw == "" {
|
||||
return filter, nil
|
||||
}
|
||||
groupID, err := strconv.ParseInt(groupRaw, 10, 64)
|
||||
if err != nil || groupID < 0 {
|
||||
return cardFilter{}, fmt.Errorf("invalid group id")
|
||||
}
|
||||
filter.groupID = groupID
|
||||
filter.filterGroup = true
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func (s *Service) adminGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
count, groupID, err := generationRequest(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := s.store.GenerateCardsInGroup(count, groupID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Service) adminSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
count, _, err := generationRequest(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := s.store.GenerateCards(count)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Service) adminCardsExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var cards []SIMCard
|
||||
if r.URL.Query().Get("selection") == "filtered" {
|
||||
filter, err := cardFilterFromRequest(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cards = s.store.QueryAllCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup)
|
||||
} else {
|
||||
cards = exportCardsByMSISDN(s.store.Snapshot().Cards, r.URL.Query().Get("msisdns"))
|
||||
}
|
||||
raw, err := exportInboundCardsXLSX(cards)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
exportedAt := time.Now().Format("2006-01-02 15:04:05")
|
||||
exportedMSISDNs := make(map[string]struct{}, len(cards))
|
||||
for _, card := range cards {
|
||||
exportedMSISDNs[card.MSISDN] = struct{}{}
|
||||
}
|
||||
if err := s.store.SaveMutation(func(st *Store) error {
|
||||
for i := range st.Cards {
|
||||
if _, ok := exportedMSISDNs[st.Cards[i].MSISDN]; ok {
|
||||
st.Cards[i].Exported = true
|
||||
st.Cards[i].ExportCheckedAt = exportedAt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="sim_card_export.xlsx"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func exportCardsByMSISDN(cards []SIMCard, rawMSISDNs string) []SIMCard {
|
||||
rawMSISDNs = strings.TrimSpace(rawMSISDNs)
|
||||
if rawMSISDNs == "" {
|
||||
return cards
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
for _, item := range strings.FieldsFunc(rawMSISDNs, func(r rune) bool {
|
||||
return r == ',' || r == '_' || r == ' '
|
||||
}) {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
selected[item] = true
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return cards
|
||||
}
|
||||
out := make([]SIMCard, 0, len(selected))
|
||||
for _, card := range cards {
|
||||
if selected[card.MSISDN] {
|
||||
out = append(out, card)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func generationCount(r *http.Request) (int, error) {
|
||||
count := 100
|
||||
if r.Body != nil {
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil && payload.Count > 0 {
|
||||
count = payload.Count
|
||||
}
|
||||
}
|
||||
if value := r.URL.Query().Get("count"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count = parsed
|
||||
}
|
||||
if count <= 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func generationRequest(r *http.Request) (int, int64, error) {
|
||||
var input struct {
|
||||
Count int `json:"count"`
|
||||
GroupID *int64 `json:"groupId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
if err == io.EOF {
|
||||
count, countErr := generationCount(r)
|
||||
return count, 0, countErr
|
||||
}
|
||||
return 0, 0, err
|
||||
}
|
||||
if input.Count <= 0 {
|
||||
return 0, 0, fmt.Errorf("count must be greater than 0")
|
||||
}
|
||||
if input.GroupID == nil || *input.GroupID < 0 {
|
||||
return input.Count, 0, nil
|
||||
}
|
||||
return input.Count, *input.GroupID, nil
|
||||
}
|
||||
|
||||
func normalizeCard(card *SIMCard) {
|
||||
now := time.Now()
|
||||
card.MSISDN = strings.TrimSpace(card.MSISDN)
|
||||
card.ICCID = strings.TrimSpace(card.ICCID)
|
||||
card.IMSI = strings.TrimSpace(card.IMSI)
|
||||
if card.CardStatus == "" {
|
||||
card.CardStatus = "2"
|
||||
}
|
||||
if card.StopReason == "" {
|
||||
card.StopReason = defaultStopReason(card.CardStatus)
|
||||
}
|
||||
if card.FrozenStatus == "" {
|
||||
card.FrozenStatus = "0"
|
||||
}
|
||||
if card.FrozenOperTime == "" {
|
||||
card.FrozenOperTime = diagnosticTime(now)
|
||||
}
|
||||
if card.LastChangeDate == "" {
|
||||
card.LastChangeDate = now.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if card.OpenDate == "" {
|
||||
card.OpenDate = now.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if card.ActiveDate == "" {
|
||||
card.ActiveDate = now.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if card.OnOffStatus == "" {
|
||||
card.OnOffStatus = "1"
|
||||
}
|
||||
if card.SessionStatus == "" {
|
||||
card.SessionStatus = "01"
|
||||
}
|
||||
if card.SessionStatus != "00" && card.SessionStatus != "01" {
|
||||
card.SessionStatus = "00"
|
||||
}
|
||||
if card.SessionCreateDate == "" {
|
||||
card.SessionCreateDate = card.LastChangeDate
|
||||
}
|
||||
if card.SessionCreateDate == "" {
|
||||
card.SessionCreateDate = card.LastChangeDate
|
||||
}
|
||||
if card.RAT == "" {
|
||||
card.RAT = "6"
|
||||
}
|
||||
if card.CardBindStatus == "" {
|
||||
card.CardBindStatus = "1"
|
||||
}
|
||||
if card.RegionLimitStatus == "" {
|
||||
card.RegionLimitStatus = "0"
|
||||
}
|
||||
if card.AreaRestrictType == "" {
|
||||
card.AreaRestrictType = "1"
|
||||
}
|
||||
if card.RestrictedArea == "" {
|
||||
card.RestrictedArea = "200"
|
||||
}
|
||||
if card.LastUsageUpdateTime == "" {
|
||||
card.LastUsageUpdateTime = now.Format(time.RFC3339)
|
||||
}
|
||||
card.GrowthPerMinuteKB = 0
|
||||
if len(card.APNs) == 0 {
|
||||
card.APNs = []APN{{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}}
|
||||
}
|
||||
for i := range card.APNs {
|
||||
apn := &card.APNs[i]
|
||||
if apn.Name == "" {
|
||||
apn.Name = "CMIOT"
|
||||
}
|
||||
if apn.Status == "" {
|
||||
apn.Status = "1"
|
||||
}
|
||||
if apn.DiagnosisStatus == "" {
|
||||
apn.DiagnosisStatus = diagnosisStatus(apn.Status)
|
||||
}
|
||||
if apn.ModifyTime == "" {
|
||||
apn.ModifyTime = diagnosticTime(now)
|
||||
}
|
||||
if apn.ServiceUsageState == "" {
|
||||
apn.ServiceUsageState = "1"
|
||||
}
|
||||
if len(apn.PCCs) == 0 {
|
||||
apn.PCCs = []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: apn.ModifyTime}}
|
||||
}
|
||||
for j := range apn.PCCs {
|
||||
if apn.PCCs[j].ServiceCode == "" {
|
||||
apn.PCCs[j].ServiceCode = "default"
|
||||
}
|
||||
if apn.PCCs[j].Name == "" {
|
||||
apn.PCCs[j].Name = "APN 缺省流量"
|
||||
}
|
||||
if apn.PCCs[j].PolicyStatus == "" {
|
||||
apn.PCCs[j].PolicyStatus = "2"
|
||||
}
|
||||
if apn.PCCs[j].ServiceUsageState == "" {
|
||||
apn.PCCs[j].ServiceUsageState = "1"
|
||||
}
|
||||
if apn.PCCs[j].ModifyTime == "" {
|
||||
apn.PCCs[j].ModifyTime = apn.ModifyTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func diagnosticTime(value time.Time) string { return value.Format("20060102150405") }
|
||||
|
||||
func defaultStopReason(cardStatus string) string {
|
||||
if cardStatus == "4" {
|
||||
return "000020000000"
|
||||
}
|
||||
return "000000000000"
|
||||
}
|
||||
|
||||
func diagnosisStatus(status string) string {
|
||||
switch status {
|
||||
case "0":
|
||||
return "4"
|
||||
case "1":
|
||||
return "2"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminAppIDUpdateClearsCachedToken(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appid := service.store.Snapshot().AppIDs[0].AppID
|
||||
if err := service.store.SaveMutation(func(st *Store) error {
|
||||
st.Tokens[appid] = Token{AppID: appid, Value: "old-token", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
st.AppIDs[0].LastToken = "old-token"
|
||||
st.AppIDs[0].TokenExpires = time.Now().Add(time.Hour).Format("2006-01-02 15:04:05")
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"appid":"` + appid + `","password":"demo_password","name":"default"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/appids", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
st := service.store.Snapshot()
|
||||
if _, ok := st.Tokens[appid]; ok {
|
||||
t.Fatalf("cached token was not cleared: %#v", st.Tokens[appid])
|
||||
}
|
||||
if st.AppIDs[0].LastToken != "" || st.AppIDs[0].TokenExpires != "" {
|
||||
t.Fatalf("appid token display was not cleared: %#v", st.AppIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsExportMarksExportedCardsAndPersists(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/onelink_mock.db"
|
||||
service, err := NewService(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+cards[0].MSISDN, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
|
||||
exportedPage := service.store.QueryCards("", "exported", 1, 20)
|
||||
if exportedPage.Total != 1 || exportedPage.Items[0].MSISDN != cards[0].MSISDN {
|
||||
t.Fatalf("exported cards = %#v", exportedPage)
|
||||
}
|
||||
if all := service.store.QueryCards("", "", 1, 20); len(all.Items) != 2 || all.Items[0].MSISDN != cards[1].MSISDN {
|
||||
t.Fatalf("cards are not newest-first: %#v", all.Items)
|
||||
}
|
||||
|
||||
reloaded, err := NewService(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persisted := reloaded.store.QueryCards("", "exported", 1, 20)
|
||||
if persisted.Total != 1 || persisted.Items[0].MSISDN != cards[0].MSISDN || persisted.Items[0].ExportCheckedAt == "" {
|
||||
t.Fatalf("persisted exported cards = %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardRegionLimitStatusPersistsAndQueries(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/onelink_mock.db"
|
||||
service, err := NewService(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := service.store.Snapshot().Cards[0]
|
||||
card.RegionLimitStatus = "1"
|
||||
body, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cards", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("save status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
|
||||
reloaded, err := NewService(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := reloaded.store.Snapshot().Cards[0].RegionLimitStatus; got != "1" {
|
||||
t.Fatalf("persisted region limit status = %q, want 1", got)
|
||||
}
|
||||
token := "region-limit-test-token"
|
||||
if err := reloaded.store.SaveMutation(func(st *Store) error {
|
||||
st.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := func(path string, target any) {
|
||||
method := http.MethodGet
|
||||
var requestBody *bytes.Reader
|
||||
if strings.HasPrefix(path, "/sim/") {
|
||||
method = http.MethodPost
|
||||
requestBody = bytes.NewReader([]byte(`{"simKeys":[{"msisdn":"` + card.MSISDN + `"}],"reqData":{}}`))
|
||||
} else {
|
||||
requestBody = bytes.NewReader(nil)
|
||||
}
|
||||
queryString := ""
|
||||
if method == http.MethodGet {
|
||||
queryString = "?token=" + token + "&msisdn=" + card.MSISDN
|
||||
}
|
||||
req := httptest.NewRequest(method, path+queryString, requestBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
reloaded.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, res.Code, res.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var oneLink Response
|
||||
query("/v5/ec/query/region-limit-status", &oneLink)
|
||||
result, ok := oneLink.Result[0].(map[string]any)
|
||||
if !ok || len(oneLink.Result) != 1 || result["regionLimitStatus"] != "1" {
|
||||
t.Fatalf("one link region response = %#v", oneLink)
|
||||
}
|
||||
var chanResponses []chanResponse[chanRegionLimitStatus]
|
||||
query("/sim/query/sim-region-limit-status", &chanResponses)
|
||||
if len(chanResponses) != 1 || chanResponses[0].ResData.RegionLimitStatus != "1" {
|
||||
t.Fatalf("chan region response = %#v", chanResponses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGenerateAppendsRequestedCount(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.NewReader(`{"count":2}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/generate", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var result GenerateResult
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Generated != 2 || result.Total != 2 {
|
||||
t.Fatalf("result = %#v, want generated 2 total 2", result)
|
||||
}
|
||||
if got := service.store.Snapshot(); len(got.Cards) != 2 {
|
||||
t.Fatalf("store cards = %d, want 2", len(got.Cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGenerateAssignsSelectedGroup(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
create := httptest.NewRequest(http.MethodPost, "/api/card-groups", strings.NewReader(`{"name":"批次一"}`))
|
||||
create.Header.Set("Content-Type", "application/json")
|
||||
createdRes := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(createdRes, create)
|
||||
if createdRes.Code != http.StatusOK {
|
||||
t.Fatalf("create group status = %d body=%s", createdRes.Code, createdRes.Body.String())
|
||||
}
|
||||
var group CardGroup
|
||||
if err := json.Unmarshal(createdRes.Body.Bytes(), &group); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
generate := httptest.NewRequest(http.MethodPost, "/api/generate", strings.NewReader(fmt.Sprintf(`{"count":2,"groupId":%d}`, group.ID)))
|
||||
generate.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, generate)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("generate status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
for _, card := range service.store.Snapshot().Cards {
|
||||
if card.GroupID != group.ID || card.GroupName != group.Name {
|
||||
t.Fatalf("card group = %#v, want %d/%s", card, group.ID, group.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsQueriesByAnyExactIdentifier(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
|
||||
for _, keyword := range []string{cards[0].MSISDN, cards[0].ICCID, cards[0].IMSI} {
|
||||
t.Run(keyword, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword="+keyword, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Items []SIMCard `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].MSISDN != cards[0].MSISDN {
|
||||
t.Fatalf("result = %#v, want the exact matching card", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsQueryReturnsEmptyPageWhenNotFound(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword=not-found", nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Items []SIMCard `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Items == nil || result.Total != 0 || len(result.Items) != 0 {
|
||||
t.Fatalf("result = %#v, want an empty page", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsExportUsesInboundExcelHeaders(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export", nil)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if contentType := res.Header().Get("Content-Type"); contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
workbookText := unzipWorkbookText(t, res.Body.Bytes())
|
||||
for _, header := range inboundExportHeaders {
|
||||
if !strings.Contains(workbookText, header) {
|
||||
t.Fatalf("export workbook missing header %q in %s", header, workbookText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(workbookText, "channel_yd_mock") || !strings.Contains(workbookText, "CMIOT-SD-500M-24M-D01") {
|
||||
t.Fatalf("export workbook missing expected generated values: %s", workbookText)
|
||||
}
|
||||
for _, marker := range []string{"sharedStrings.xml", "styles.xml"} {
|
||||
if !zipContainsFile(t, res.Body.Bytes(), "xl/"+marker) {
|
||||
t.Fatalf("export workbook missing xl/%s", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func zipContainsFile(t *testing.T, raw []byte, name string) bool {
|
||||
t.Helper()
|
||||
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range reader.File {
|
||||
if file.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAdminCardsExportFiltersSelectedMSISDNs(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
selected := cards[0].MSISDN + "," + cards[2].MSISDN
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+selected, nil)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
workbookText := unzipWorkbookText(t, res.Body.Bytes())
|
||||
if !strings.Contains(workbookText, cards[0].MSISDN) || !strings.Contains(workbookText, cards[2].MSISDN) {
|
||||
t.Fatalf("export workbook missing selected cards: %s", workbookText)
|
||||
}
|
||||
if strings.Contains(workbookText, cards[1].MSISDN) {
|
||||
t.Fatalf("export workbook included unselected card %s: %s", cards[1].MSISDN, workbookText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsExportFilteredResultsAcrossPages(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdAt := time.Now().Format("2006-01-02 15:04:05")
|
||||
if err = service.store.SaveMutation(func(st *Store) error {
|
||||
st.Groups = append(st.Groups,
|
||||
CardGroup{ID: 7, Name: "group-seven", CreatedAt: createdAt, UpdatedAt: createdAt},
|
||||
CardGroup{ID: 8, Name: "group-eight", CreatedAt: createdAt, UpdatedAt: createdAt},
|
||||
)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.store.GenerateCardsInGroup(25, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.store.GenerateCardsInGroup(5, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupSevenCards := service.store.QueryAllCardsByGroup("", "", 7, true)
|
||||
if len(groupSevenCards) != 25 {
|
||||
t.Fatalf("group 7 cards = %d, want 25", len(groupSevenCards))
|
||||
}
|
||||
alreadyExported := groupSevenCards[0].MSISDN
|
||||
if err = service.store.SaveMutation(func(st *Store) error {
|
||||
for i := range st.Cards {
|
||||
if st.Cards[i].MSISDN == alreadyExported {
|
||||
st.Cards[i].Exported = true
|
||||
st.Cards[i].ExportCheckedAt = "2026-08-27 00:00:00"
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&groupId=7&exportStatus=not_exported", nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
workbookText := unzipWorkbookText(t, res.Body.Bytes())
|
||||
for _, card := range groupSevenCards[1:] {
|
||||
if !strings.Contains(workbookText, card.MSISDN) {
|
||||
t.Fatalf("export workbook missing filtered card %s", card.MSISDN)
|
||||
}
|
||||
}
|
||||
if strings.Contains(workbookText, alreadyExported) {
|
||||
t.Fatalf("export workbook included already exported card %s", alreadyExported)
|
||||
}
|
||||
for _, card := range service.store.QueryAllCardsByGroup("", "", 8, true) {
|
||||
if strings.Contains(workbookText, card.MSISDN) {
|
||||
t.Fatalf("export workbook included card from another group %s", card.MSISDN)
|
||||
}
|
||||
}
|
||||
if exported := service.store.QueryAllCardsByGroup("", "exported", 7, true); len(exported) != 25 {
|
||||
t.Fatalf("exported group 7 cards = %d, want 25", len(exported))
|
||||
}
|
||||
if exported := service.store.QueryAllCardsByGroup("", "exported", 8, true); len(exported) != 0 {
|
||||
t.Fatalf("exported group 8 cards = %d, want 0", len(exported))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsExportFilteredResultsByKeyword(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.store.GenerateCards(3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&keyword="+cards[1].ICCID, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
workbookText := unzipWorkbookText(t, res.Body.Bytes())
|
||||
if !strings.Contains(workbookText, cards[1].MSISDN) {
|
||||
t.Fatalf("export workbook missing keyword match %s", cards[1].MSISDN)
|
||||
}
|
||||
for _, card := range []SIMCard{cards[0], cards[2]} {
|
||||
if strings.Contains(workbookText, card.MSISDN) {
|
||||
t.Fatalf("export workbook included non-matching card %s", card.MSISDN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCardsExportFilteredResultsRejectsInvalidFilter(t *testing.T) {
|
||||
service, err := NewService(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&exportStatus=invalid", nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func unzipWorkbookText(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, file := range reader.File {
|
||||
if !strings.HasSuffix(file.Name, ".xml") {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(&out, rc)
|
||||
_ = rc.Close()
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
chanSuccessCode = 0
|
||||
chanErrorCode = 9999
|
||||
)
|
||||
|
||||
type chanSimKey struct {
|
||||
ChanKey string `json:"chanKey"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
}
|
||||
|
||||
type chanRequest struct {
|
||||
SimKeys []chanSimKey `json:"simKeys"`
|
||||
ReqData json.RawMessage `json:"reqData"`
|
||||
}
|
||||
|
||||
type chanResponse[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
SimKey chanSimKey `json:"simKey"`
|
||||
ResData T `json:"resData"`
|
||||
}
|
||||
|
||||
type chanBasicInfo struct {
|
||||
ActiveDate string `json:"activeDate"`
|
||||
OpenDate string `json:"openDate"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type chanSession struct {
|
||||
ApnID string `json:"apnId"`
|
||||
Rat string `json:"rat"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6Prefix string `json:"ipv6Prefix"`
|
||||
Status string `json:"status"`
|
||||
CreateTime string `json:"createTime"`
|
||||
}
|
||||
|
||||
type chanCardStatus struct {
|
||||
CardStatus int `json:"cardStatus"`
|
||||
LastChangeDate string `json:"lastChangeDate"`
|
||||
}
|
||||
|
||||
type chanIMEI struct {
|
||||
IMEI string `json:"imei"`
|
||||
}
|
||||
|
||||
type chanUsage struct {
|
||||
DataUsage float64 `json:"dataUsage"`
|
||||
}
|
||||
|
||||
type chanMonthlyUsage struct {
|
||||
DeadLine string `json:"deadLine"`
|
||||
DataAmount float64 `json:"dataAmount"`
|
||||
}
|
||||
|
||||
type chanDailyUsage struct {
|
||||
QueryDate string `json:"queryDate"`
|
||||
DataAmount float64 `json:"dataAmount"`
|
||||
}
|
||||
|
||||
type chanAPN struct {
|
||||
APNName string `json:"apnName"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type chanAPNList struct {
|
||||
APNList []chanAPN `json:"apnList"`
|
||||
}
|
||||
|
||||
type chanOrder struct {
|
||||
OrderNum string `json:"orderNum"`
|
||||
}
|
||||
|
||||
type chanBindStatus struct {
|
||||
BindStatus string `json:"bindStatus"`
|
||||
}
|
||||
|
||||
type chanDeviceStatus struct {
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
}
|
||||
|
||||
type chanRegionLimitStatus struct {
|
||||
RegionLimitStatus string `json:"regionLimitStatus"`
|
||||
}
|
||||
|
||||
type chanReqData struct {
|
||||
QueryDate string `json:"queryDate"`
|
||||
OperType string `json:"operType"`
|
||||
APNName string `json:"apnName"`
|
||||
TestType *int `json:"testType"`
|
||||
}
|
||||
|
||||
func (s *Service) chanAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/sim/")
|
||||
switch path {
|
||||
case "query/sim-basic-info":
|
||||
s.chanQueryBasicInfo(w, r)
|
||||
case "query/sim-session":
|
||||
s.chanQuerySession(w, r)
|
||||
case "query/sim-status", "query/sim-status-batch":
|
||||
s.chanQueryStatus(w, r)
|
||||
case "query/sim-card-bind-status":
|
||||
s.chanQueryBindStatus(w, r)
|
||||
case "query/sim-device-status":
|
||||
s.chanQueryDeviceStatus(w, r)
|
||||
case "query/sim-region-limit-status":
|
||||
s.chanQueryRegionLimitStatus(w, r)
|
||||
case "query/sim-imei":
|
||||
s.chanQueryIMEI(w, r)
|
||||
case "query/sim-datausage":
|
||||
s.chanQueryDataUsage(w, r)
|
||||
case "query/sim-datausage-monthly", "query/sim-datausage-monthly-batch":
|
||||
s.chanQueryMonthly(w, r)
|
||||
case "query/sim-datausage-daily", "query/sim-datausage-daily-batch":
|
||||
s.chanQueryDaily(w, r)
|
||||
case "query/sim-apn-info":
|
||||
s.chanQueryAPN(w, r)
|
||||
case "change/sim-status":
|
||||
s.chanChangeStatus(w, r)
|
||||
case "change/sim-apn":
|
||||
s.chanChangeAPN(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"message": "unknown sim endpoint"})
|
||||
}
|
||||
}
|
||||
|
||||
func parseChanRequest(r *http.Request) (chanRequest, error) {
|
||||
var req chanRequest
|
||||
if r.Method != http.MethodGet && r.Body != nil {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(body))) > 0 {
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return req, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(req.SimKeys) == 0 {
|
||||
req.SimKeys = simKeysFromQuery(r.URL.Query())
|
||||
}
|
||||
if len(req.ReqData) == 0 {
|
||||
data := chanReqData{
|
||||
QueryDate: r.URL.Query().Get("queryDate"),
|
||||
OperType: r.URL.Query().Get("operType"),
|
||||
APNName: r.URL.Query().Get("apnName"),
|
||||
}
|
||||
if value := r.URL.Query().Get("testType"); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
data.TestType = &parsed
|
||||
}
|
||||
}
|
||||
if data.QueryDate != "" || data.OperType != "" || data.APNName != "" || data.TestType != nil {
|
||||
req.ReqData, _ = json.Marshal(data)
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func simKeysFromQuery(q url.Values) []chanSimKey {
|
||||
if raw := q.Get("simKeys"); raw != "" {
|
||||
var keys []chanSimKey
|
||||
if json.Unmarshal([]byte(raw), &keys) == nil {
|
||||
return keys
|
||||
}
|
||||
}
|
||||
var values []chanSimKey
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
name string
|
||||
}{
|
||||
{"iccids", "iccid"}, {"imsis", "imsi"}, {"msisdns", "msisdn"},
|
||||
} {
|
||||
for _, value := range splitIDs(q.Get(item.key)) {
|
||||
key := chanSimKey{}
|
||||
switch item.name {
|
||||
case "iccid":
|
||||
key.ICCID = value
|
||||
case "imsi":
|
||||
key.IMSI = value
|
||||
case "msisdn":
|
||||
key.MSISDN = value
|
||||
}
|
||||
values = append(values, key)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
key := chanSimKey{ICCID: q.Get("iccid"), IMSI: q.Get("imsi"), MSISDN: q.Get("msisdn")}
|
||||
if key.ICCID != "" || key.IMSI != "" || key.MSISDN != "" {
|
||||
values = append(values, key)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func splitIDs(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, ",", "_")
|
||||
parts := strings.Split(raw, "_")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func decodeChanReqData(req chanRequest) chanReqData {
|
||||
var data chanReqData
|
||||
_ = json.Unmarshal(req.ReqData, &data)
|
||||
return data
|
||||
}
|
||||
|
||||
func chanSuccess[T any](key chanSimKey, data T) chanResponse[T] {
|
||||
return chanResponse[T]{Code: chanSuccessCode, Message: "success", SimKey: key, ResData: data}
|
||||
}
|
||||
|
||||
func chanFailure[T any](key chanSimKey, message string) chanResponse[T] {
|
||||
return chanResponse[T]{Code: chanErrorCode, Message: message, SimKey: key, ResData: *new(T)}
|
||||
}
|
||||
|
||||
func writeChanResponses[T any](w http.ResponseWriter, responses []chanResponse[T]) {
|
||||
writeJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanCards(r *http.Request) (chanRequest, Store, error) {
|
||||
req, err := parseChanRequest(r)
|
||||
if err != nil {
|
||||
return req, Store{}, err
|
||||
}
|
||||
return req, s.store.Snapshot(), nil
|
||||
}
|
||||
|
||||
func findChanCard(st Store, key chanSimKey) (SIMCard, bool) {
|
||||
if key.ICCID != "" {
|
||||
for _, card := range st.Cards {
|
||||
if card.ICCID == key.ICCID {
|
||||
return card, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if key.IMSI != "" {
|
||||
for _, card := range st.Cards {
|
||||
if card.IMSI == key.IMSI {
|
||||
return card, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if key.MSISDN != "" {
|
||||
for _, card := range st.Cards {
|
||||
if card.MSISDN == key.MSISDN {
|
||||
return card, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return SIMCard{}, false
|
||||
}
|
||||
|
||||
func chanRequestError[T any](w http.ResponseWriter, err error) {
|
||||
writeJSON(w, http.StatusBadRequest, []chanResponse[T]{{Code: chanErrorCode, Message: err.Error()}})
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryBasicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanBasicInfo](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanBasicInfo], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanBasicInfo](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanBasicInfo{ActiveDate: card.ActiveDate, OpenDate: card.OpenDate, Remark: card.Remark}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQuerySession(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanSession](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanSession], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanSession](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanSession{ApnID: primaryAPN(card).Name, Rat: card.RAT, IPv4: card.IP, IPv6: card.IPv6, IPv6Prefix: card.IPv6Prefix, Status: chanSessionStatus(card.SessionStatus), CreateTime: card.LastChangeDate}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanCardStatus](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanCardStatus], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanCardStatus](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
status, _ := strconv.Atoi(card.CardStatus)
|
||||
responses = append(responses, chanSuccess(key, chanCardStatus{CardStatus: status, LastChangeDate: card.LastChangeDate}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryBindStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanBindStatus](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanBindStatus], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanBindStatus](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanBindStatus{BindStatus: card.CardBindStatus}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryDeviceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanDeviceStatus](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanDeviceStatus], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanDeviceStatus](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanDeviceStatus{DeviceStatus: chanSessionStatus(card.OnOffStatus)}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryRegionLimitStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanRegionLimitStatus](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanRegionLimitStatus], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanRegionLimitStatus](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanRegionLimitStatus{RegionLimitStatus: card.RegionLimitStatus}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryIMEI(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanIMEI](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanIMEI], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanIMEI](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanIMEI{IMEI: card.IMEI}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryDataUsage(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanUsage](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanUsage], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanUsage](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanUsage{DataUsage: card.MonthUsageKB / 1024}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryMonthly(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanMonthlyUsage](w, err)
|
||||
return
|
||||
}
|
||||
data := decodeChanReqData(req)
|
||||
responses := make([]chanResponse[chanMonthlyUsage], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanMonthlyUsage](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
amount := card.MonthUsageKB
|
||||
if data.QueryDate != "" && len(data.QueryDate) == 6 && data.QueryDate != time.Now().Format("200601") {
|
||||
amount = card.MonthlyBaseKB
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanMonthlyUsage{DeadLine: time.Now().AddDate(0, 0, -1).Format("20060102"), DataAmount: amount / 1024}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryDaily(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanDailyUsage](w, err)
|
||||
return
|
||||
}
|
||||
data := decodeChanReqData(req)
|
||||
queryDate := data.QueryDate
|
||||
if queryDate == "" {
|
||||
queryDate = r.URL.Query().Get("queryDate")
|
||||
}
|
||||
if queryDate == "" {
|
||||
queryDate = time.Now().Format("20060102")
|
||||
}
|
||||
responses := make([]chanResponse[chanDailyUsage], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanDailyUsage](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanDailyUsage{QueryDate: queryDate, DataAmount: card.DailyBaseKB / 1024}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanQueryAPN(w http.ResponseWriter, r *http.Request) {
|
||||
req, st, err := s.chanCards(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanAPNList](w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanAPNList], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
card, ok := findChanCard(st, key)
|
||||
if !ok {
|
||||
responses = append(responses, chanFailure[chanAPNList](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
apns := make([]chanAPN, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
apns = append(apns, chanAPN{APNName: apn.Name, Status: apn.Status})
|
||||
}
|
||||
responses = append(responses, chanSuccess(key, chanAPNList{APNList: apns}))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanChangeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := parseChanRequest(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanOrder](w, err)
|
||||
return
|
||||
}
|
||||
data := decodeChanReqData(req)
|
||||
if data.OperType != "0" && data.OperType != "1" {
|
||||
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
responses = append(responses, chanFailure[chanOrder](key, "unsupported operType, expected 0(stop) or 1(resume)"))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
|
||||
err = s.store.SaveMutation(func(st *Store) error {
|
||||
for _, key := range req.SimKeys {
|
||||
idx := findChanCardIndex(*st, key)
|
||||
if idx < 0 {
|
||||
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
card := &st.Cards[idx]
|
||||
if data.OperType == "0" {
|
||||
card.CardStatus = "4"
|
||||
} else {
|
||||
card.CardStatus = "2"
|
||||
}
|
||||
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
|
||||
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
|
||||
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-status", OperType: data.OperType, Status: "completed", CreatedAt: card.LastChangeDate})
|
||||
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
chanRequestError[chanOrder](w, err)
|
||||
return
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func (s *Service) chanChangeAPN(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := parseChanRequest(r)
|
||||
if err != nil {
|
||||
chanRequestError[chanOrder](w, err)
|
||||
return
|
||||
}
|
||||
data := decodeChanReqData(req)
|
||||
if data.APNName == "" || (data.OperType != "0" && data.OperType != "1") {
|
||||
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
|
||||
for _, key := range req.SimKeys {
|
||||
responses = append(responses, chanFailure[chanOrder](key, "apnName and operType are required"))
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
return
|
||||
}
|
||||
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
|
||||
err = s.store.SaveMutation(func(st *Store) error {
|
||||
for _, key := range req.SimKeys {
|
||||
idx := findChanCardIndex(*st, key)
|
||||
if idx < 0 {
|
||||
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
|
||||
continue
|
||||
}
|
||||
card := &st.Cards[idx]
|
||||
status := "0"
|
||||
if data.OperType == "0" {
|
||||
status = "1"
|
||||
}
|
||||
found := false
|
||||
for i := range card.APNs {
|
||||
if strings.EqualFold(card.APNs[i].Name, data.APNName) {
|
||||
card.APNs[i].Status = status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
card.APNs = append(card.APNs, APN{Name: data.APNName, Status: status})
|
||||
}
|
||||
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-apn-function", OperType: data.OperType, APNName: data.APNName, Status: "completed", CreatedAt: now})
|
||||
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
chanRequestError[chanOrder](w, err)
|
||||
return
|
||||
}
|
||||
writeChanResponses(w, responses)
|
||||
}
|
||||
|
||||
func findChanCardIndex(st Store, key chanSimKey) int {
|
||||
for i, card := range st.Cards {
|
||||
if (key.ICCID != "" && card.ICCID == key.ICCID) || (key.IMSI != "" && card.IMSI == key.IMSI) || (key.MSISDN != "" && card.MSISDN == key.MSISDN) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func chanSessionStatus(status string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case "01", "1", "0", "ONLINE":
|
||||
return "0"
|
||||
case "00", "-1", "OFFLINE":
|
||||
return "-1"
|
||||
default:
|
||||
return "-2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChanQueryEndpoints(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"simKeys": []map[string]string{{"chanKey": "channel_yd_mock", "iccid": cards[0].ICCID, "imsi": cards[0].IMSI, "msisdn": cards[0].MSISDN}},
|
||||
"reqData": map[string]string{"queryDate": "20260101"},
|
||||
})
|
||||
paths := []string{
|
||||
"/sim/query/sim-basic-info",
|
||||
"/sim/query/sim-session",
|
||||
"/sim/query/sim-status",
|
||||
"/sim/query/sim-status-batch",
|
||||
"/sim/query/sim-card-bind-status",
|
||||
"/sim/query/sim-device-status",
|
||||
"/sim/query/sim-region-limit-status",
|
||||
"/sim/query/sim-imei",
|
||||
"/sim/query/sim-datausage",
|
||||
"/sim/query/sim-datausage-monthly",
|
||||
"/sim/query/sim-datausage-monthly-batch",
|
||||
"/sim/query/sim-datausage-daily",
|
||||
"/sim/query/sim-datausage-daily-batch",
|
||||
"/sim/query/sim-apn-info",
|
||||
}
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var responses []struct {
|
||||
Code int `json:"code"`
|
||||
SimKey chanSimKey `json:"simKey"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(responses) != 1 || responses[0].Code != chanSuccessCode || responses[0].SimKey.ICCID != cards[0].ICCID {
|
||||
t.Fatalf("responses = %#v", responses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanBatchAndQueryStringCompatibility(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards := service.store.Snapshot().Cards
|
||||
req := httptest.NewRequest(http.MethodGet, "/sim/query/sim-status-batch?iccids="+cards[0].ICCID+"_"+cards[1].ICCID, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var responses []chanResponse[chanCardStatus]
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(responses) != 2 || responses[0].Code != chanSuccessCode || responses[1].Code != chanSuccessCode {
|
||||
t.Fatalf("responses = %#v", responses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanChangesPersistAndCreateOrders(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.store.GenerateCards(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := service.store.Snapshot().Cards[0]
|
||||
statusBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"operType":"0"}}`)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/sim/change/sim-status", bytes.NewReader(statusBody))
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status change status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if got := service.store.Snapshot().Cards[0].CardStatus; got != "4" {
|
||||
t.Fatalf("card status = %s, want 4", got)
|
||||
}
|
||||
apnBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"apnName":"TEST_APN","operType":"0"}}`)
|
||||
res = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/sim/change/sim-apn", bytes.NewReader(apnBody))
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("apn change status = %d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
updated := service.store.Snapshot()
|
||||
if len(updated.Orders) != 2 {
|
||||
t.Fatalf("orders = %d, want 2", len(updated.Orders))
|
||||
}
|
||||
found := false
|
||||
for _, apn := range updated.Cards[0].APNs {
|
||||
if apn.Name == "TEST_APN" && apn.Status == "1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("updated APNs = %#v", updated.Cards[0].APNs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneLinkDataDiagnosisUsesConfiguredAPNAndPCC(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := service.store.Snapshot()
|
||||
st.Cards = GenerateSIMCards(time.Now(), 1)
|
||||
card := &st.Cards[0]
|
||||
card.APNs[0].DiagnosisStatus = "4"
|
||||
card.APNs[0].ServiceUsageState = "96"
|
||||
card.APNs[0].OverUseDetails = "16|17"
|
||||
card.APNs[0].PCCs = []PCC{{ServiceCode: "2323232", Name: "APN 缺省流量", PolicyStatus: "4", ServiceUsageState: "1", ModifyTime: "20220812154700"}}
|
||||
if err := service.store.SaveMutation(func(next *Store) error { *next = st; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st = service.store.Snapshot()
|
||||
token := "diagnosis-test-token"
|
||||
if err := service.store.SaveMutation(func(next *Store) error {
|
||||
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/sim-data-diagnosis?token="+token+"&msisdn="+st.Cards[0].MSISDN, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var body Response
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Status != "0" {
|
||||
t.Fatalf("response=%s", res.Body.String())
|
||||
}
|
||||
encoded, _ := json.Marshal(body.Result)
|
||||
text := string(encoded)
|
||||
for _, want := range []string{"\"apnStatus\":\"4\"", "\"apnServiceUsageState\":\"96\"", "\"overUseDetails\":\"16|17\"", "\"pccServiceCode\":\"2323232\"", "\"pccPolicyStatus\":\"4\""} {
|
||||
if !contains(text, want) {
|
||||
t.Fatalf("missing %s in %s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardBindStatusRequiresMSISDNAndTestType(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := service.store.Snapshot()
|
||||
st.Cards = GenerateSIMCards(time.Now(), 1)
|
||||
token := "bind-test-token"
|
||||
if err := service.store.SaveMutation(func(next *Store) error {
|
||||
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, query := range []string{"iccid=" + st.Cards[0].ICCID + "&testType=0", "msisdn=" + st.Cards[0].MSISDN} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/card-bind-status?token="+token+"&"+query, nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
var body Response
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Status == "0" {
|
||||
t.Fatalf("expected validation failure for %s", query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nowPlusHour() (value time.Time) { return time.Now().Add(time.Hour) }
|
||||
func contains(value, want string) bool { return strings.Contains(value, want) }
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func exportInboundCardsXLSX(cards []SIMCard) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
files := map[string]string{
|
||||
"[Content_Types].xml": contentTypesXML,
|
||||
"_rels/.rels": rootRelsXML,
|
||||
"xl/workbook.xml": workbookXML,
|
||||
"xl/_rels/workbook.xml.rels": workbookRelsXML,
|
||||
"xl/sharedStrings.xml": sharedStringsXML(),
|
||||
"xl/styles.xml": stylesXML,
|
||||
"xl/worksheets/sheet1.xml": sheetXML(cards),
|
||||
}
|
||||
for _, name := range []string{"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/sharedStrings.xml", "xl/styles.xml", "xl/worksheets/sheet1.xml"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write([]byte(files[name])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sheetXML(cards []SIMCard) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
|
||||
b.WriteString(`<dimension ref="A1:R`)
|
||||
b.WriteString(strconv.Itoa(len(cards) + 1))
|
||||
b.WriteString(`"/><sheetViews><sheetView tabSelected="true" workbookViewId="0"/></sheetViews>`)
|
||||
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/><cols><col min="1" max="18" width="9.140625" style="1"/></cols><sheetData>`)
|
||||
writeSharedStringRow(&b, 1, len(inboundExportHeaders))
|
||||
for i, card := range cards {
|
||||
row := inboundExportRow(card)
|
||||
values := make([]string, 0, len(inboundExportColumns))
|
||||
for _, column := range inboundExportColumns {
|
||||
values = append(values, row[column.Key])
|
||||
}
|
||||
writeSheetRow(&b, i+2, values)
|
||||
}
|
||||
b.WriteString(`</sheetData></worksheet>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeSharedStringRow(b *strings.Builder, rowNum, count int) {
|
||||
b.WriteString(`<row r="`)
|
||||
b.WriteString(strconv.Itoa(rowNum))
|
||||
b.WriteString(`">`)
|
||||
for i := 0; i < count; i++ {
|
||||
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
|
||||
b.WriteString(`<c r="`)
|
||||
b.WriteString(ref)
|
||||
b.WriteString(`" s="1" t="s"><v>`)
|
||||
b.WriteString(strconv.Itoa(i))
|
||||
b.WriteString(`</v></c>`)
|
||||
}
|
||||
b.WriteString(`</row>`)
|
||||
}
|
||||
|
||||
func writeSheetRow(b *strings.Builder, rowNum int, values []string) {
|
||||
b.WriteString(`<row r="`)
|
||||
b.WriteString(strconv.Itoa(rowNum))
|
||||
b.WriteString(`">`)
|
||||
for i, value := range values {
|
||||
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
|
||||
b.WriteString(`<c r="`)
|
||||
b.WriteString(ref)
|
||||
b.WriteString(`" s="1" t="inlineStr"><is><t>`)
|
||||
b.WriteString(xmlEscape(value))
|
||||
b.WriteString(`</t></is></c>`)
|
||||
}
|
||||
b.WriteString(`</row>`)
|
||||
}
|
||||
|
||||
func sharedStringsXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="`)
|
||||
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
|
||||
b.WriteString(`" uniqueCount="`)
|
||||
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
|
||||
b.WriteString(`">`)
|
||||
for _, header := range inboundExportHeaders {
|
||||
b.WriteString(`<si><t>`)
|
||||
b.WriteString(xmlEscape(header))
|
||||
b.WriteString(`</t></si>`)
|
||||
}
|
||||
b.WriteString(`</sst>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func columnName(index int) string {
|
||||
var out []byte
|
||||
for index > 0 {
|
||||
index--
|
||||
out = append([]byte{byte('A' + index%26)}, out...)
|
||||
index /= 26
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func xmlEscape(value string) string {
|
||||
var b strings.Builder
|
||||
_ = xml.EscapeText(&b, []byte(value))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>`
|
||||
|
||||
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||
<dxfs count="0"/><tableStyles count="0" defaultPivotStyle="PivotStyleLight16" defaultTableStyle="TableStyleMedium2"/>
|
||||
</styleSheet>`
|
||||
@@ -0,0 +1,134 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
generatedChanKey = "channel_yd_mock"
|
||||
generatedCardType = "1"
|
||||
generatedPackageCode = "CMIOT-SD-500M-24M-D01"
|
||||
generatedSupplierCode = "SUP_CMCC"
|
||||
generatedMonthlyUsage = 18432.0
|
||||
generatedDailyUsage = 614.4
|
||||
generatedPoolUsageByte = 18874368.0
|
||||
)
|
||||
|
||||
type inboundExportColumn struct {
|
||||
Header string
|
||||
Key string
|
||||
}
|
||||
|
||||
var inboundExportColumns = []inboundExportColumn{
|
||||
{Header: "ICCID*", Key: "ICCID"},
|
||||
{Header: "MSISDN*", Key: "MSISDN"},
|
||||
{Header: "IMSI*", Key: "IMSI"},
|
||||
{Header: "IMEI", Key: "IMEI"},
|
||||
{Header: "通道编号*", Key: "chanKey"},
|
||||
{Header: "卡类型", Key: "cardType"},
|
||||
{Header: "上游套餐编码", Key: "upPkgCode"},
|
||||
{Header: "供应商编码", Key: "suppliersCode"},
|
||||
{Header: "上游激活时间", Key: "upstreamActivationTime"},
|
||||
{Header: "是否贴片转插拔", Key: "insertToPlugEnabled"},
|
||||
{Header: "是否运营商达量断网", Key: "carrierTrafficCutoffEnabled"},
|
||||
{Header: "协议到期时间", Key: "agreementExpiryDate"},
|
||||
{Header: "是否有测试期", Key: "hasTestPeriod"},
|
||||
{Header: "测试期天数", Key: "testPeriodDays"},
|
||||
{Header: "测试期最晚过期日期", Key: "testPeriodLatestExpiryDate"},
|
||||
{Header: "是否有沉默期", Key: "hasSilentPeriod"},
|
||||
{Header: "沉默期天数", Key: "silentPeriodDays"},
|
||||
{Header: "沉默期最晚过期日期", Key: "silentPeriodLatestExpiryDate"},
|
||||
}
|
||||
|
||||
var inboundExportHeaders = func() []string {
|
||||
headers := make([]string, 0, len(inboundExportColumns))
|
||||
for _, column := range inboundExportColumns {
|
||||
headers = append(headers, column.Header)
|
||||
}
|
||||
return headers
|
||||
}()
|
||||
|
||||
func GenerateSIMCards(batchTime time.Time, count int) []SIMCard {
|
||||
if count <= 0 {
|
||||
return []SIMCard{}
|
||||
}
|
||||
cards := make([]SIMCard, 0, count)
|
||||
base := batchTime.UnixMilli()
|
||||
dbTime := batchTime.Format("2006-01-02 15:04:05")
|
||||
for i := 0; i < count; i++ {
|
||||
seq := base + int64(i+1)
|
||||
card := SIMCard{
|
||||
MSISDN: fmt.Sprintf("141%08d", seq%100000000),
|
||||
ICCID: fmt.Sprintf("898604%014d", seq%100000000000000),
|
||||
IMSI: fmt.Sprintf("46004%010d", seq%10000000000),
|
||||
IMEI: fmt.Sprintf("86%013d", seq%10000000000000),
|
||||
Remark: sourceRemark("CMCC", generatedPackageCode, generatedPackageCode),
|
||||
CardStatus: "2",
|
||||
LastChangeDate: dbTime,
|
||||
StopReason: "000000000000",
|
||||
FrozenStatus: "0",
|
||||
FrozenOperTime: batchTime.Format("20060102150405"),
|
||||
OpenDate: dbTime,
|
||||
ActiveDate: dbTime,
|
||||
OnOffStatus: "1",
|
||||
SessionStatus: "01",
|
||||
IP: fmt.Sprintf("10.%d.%d.%d", (seq/10000)%255, (seq/100)%255, seq%255),
|
||||
IPv6Prefix: "",
|
||||
IPv6: "",
|
||||
RAT: "6",
|
||||
CardBindStatus: "1",
|
||||
RegionLimitStatus: "0",
|
||||
AreaRestrictType: "1",
|
||||
RestrictedArea: "110000,310000,440000",
|
||||
MonthUsageKB: generatedMonthlyUsage,
|
||||
PoolUsageBytes: generatedPoolUsageByte,
|
||||
DailyBaseKB: generatedDailyUsage,
|
||||
MonthlyBaseKB: generatedMonthlyUsage,
|
||||
GrowthPerMinuteKB: 0,
|
||||
LastUsageUpdateTime: batchTime.Format(time.RFC3339),
|
||||
APNs: []APN{
|
||||
{Name: "CMIOT", Status: "1", UsageBytes: generatedPoolUsageByte, ModifyTime: batchTime.Format("20060102150405"), ServiceUsageState: "1", PCCs: []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: batchTime.Format("20060102150405")}}},
|
||||
},
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func inboundExportRow(card SIMCard) map[string]string {
|
||||
activation := parseCardTime(card.ActiveDate)
|
||||
return map[string]string{
|
||||
"ICCID": card.ICCID,
|
||||
"MSISDN": card.MSISDN,
|
||||
"IMSI": card.IMSI,
|
||||
"IMEI": card.IMEI,
|
||||
"chanKey": generatedChanKey,
|
||||
"cardType": generatedCardType,
|
||||
"upPkgCode": generatedPackageCode,
|
||||
"suppliersCode": generatedSupplierCode,
|
||||
"upstreamActivationTime": activation.Format("2006-01-02 15:04:05"),
|
||||
"insertToPlugEnabled": "true",
|
||||
"carrierTrafficCutoffEnabled": "false",
|
||||
"agreementExpiryDate": activation.AddDate(1, 0, 0).Format("2006-01-02"),
|
||||
"hasTestPeriod": "true",
|
||||
"testPeriodDays": "30",
|
||||
"testPeriodLatestExpiryDate": activation.AddDate(0, 0, 30).Format("2006-01-02"),
|
||||
"hasSilentPeriod": "false",
|
||||
"silentPeriodDays": "",
|
||||
"silentPeriodLatestExpiryDate": "",
|
||||
}
|
||||
}
|
||||
|
||||
func parseCardTime(value string) time.Time {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, time.RFC3339Nano} {
|
||||
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
if unixMilli, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return time.UnixMilli(unixMilli)
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInboundExportHeadersMatchLatestTemplate(t *testing.T) {
|
||||
expected := []string{
|
||||
"ICCID*", "MSISDN*", "IMSI*", "IMEI", "通道编号*", "卡类型", "上游套餐编码", "供应商编码",
|
||||
"上游激活时间", "是否贴片转插拔", "是否运营商达量断网", "协议到期时间", "是否有测试期",
|
||||
"测试期天数", "测试期最晚过期日期", "是否有沉默期", "沉默期天数", "沉默期最晚过期日期",
|
||||
}
|
||||
if !reflect.DeepEqual(inboundExportHeaders, expected) {
|
||||
t.Fatalf("export headers = %#v, want %#v", inboundExportHeaders, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedCardsUseTimestampIdentifiersAndFixedTraffic(t *testing.T) {
|
||||
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 123000000, time.UTC)
|
||||
|
||||
cards := GenerateSIMCards(batchTime, 3)
|
||||
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("generated card count = %d, want 3", len(cards))
|
||||
}
|
||||
first := cards[0]
|
||||
second := cards[1]
|
||||
if first.ICCID == second.ICCID || first.MSISDN == second.MSISDN || first.IMSI == second.IMSI || first.IMEI == second.IMEI {
|
||||
t.Fatalf("generated identifiers should differ within a batch: first=%#v second=%#v", first, second)
|
||||
}
|
||||
if len(first.ICCID) != 20 || first.ICCID[:6] != "898604" {
|
||||
t.Fatalf("iccid = %q, want 20 digits with 898604 prefix", first.ICCID)
|
||||
}
|
||||
if len(first.MSISDN) != 11 || first.MSISDN[:3] != "141" {
|
||||
t.Fatalf("msisdn = %q, want 11 digits with 141 prefix", first.MSISDN)
|
||||
}
|
||||
if len(first.IMSI) != 15 || first.IMSI[:5] != "46004" {
|
||||
t.Fatalf("imsi = %q, want 15 digits with 46004 prefix", first.IMSI)
|
||||
}
|
||||
if len(first.IMEI) != 15 || first.IMEI[:2] != "86" {
|
||||
t.Fatalf("imei = %q, want 15 digits with 86 prefix", first.IMEI)
|
||||
}
|
||||
if first.MonthUsageKB != 18432 || first.MonthlyBaseKB != 18432 || first.DailyBaseKB != 614.4 {
|
||||
t.Fatalf("fixed traffic not applied: month=%v monthly=%v daily=%v", first.MonthUsageKB, first.MonthlyBaseKB, first.DailyBaseKB)
|
||||
}
|
||||
if first.PoolUsageBytes != 18874368 {
|
||||
t.Fatalf("pool usage bytes = %v, want 18874368", first.PoolUsageBytes)
|
||||
}
|
||||
if first.GrowthPerMinuteKB != 0 {
|
||||
t.Fatalf("growth per minute = %v, want 0", first.GrowthPerMinuteKB)
|
||||
}
|
||||
if len(first.APNs) != 1 || first.APNs[0].Name != "CMIOT" || first.APNs[0].Status != "1" || first.APNs[0].UsageBytes != 18874368 {
|
||||
t.Fatalf("apn not fixed to CMIOT usage: %#v", first.APNs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedExportRowMatchesInboundTemplateDefaults(t *testing.T) {
|
||||
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
card := GenerateSIMCards(batchTime, 1)[0]
|
||||
|
||||
row := inboundExportRow(card)
|
||||
|
||||
if row["chanKey"] != "channel_yd_mock" {
|
||||
t.Fatalf("chanKey = %q", row["chanKey"])
|
||||
}
|
||||
if row["cardType"] != "1" || row["upPkgCode"] != "CMIOT-SD-500M-24M-D01" || row["suppliersCode"] != "SUP_CMCC" {
|
||||
t.Fatalf("package defaults not applied: %#v", row)
|
||||
}
|
||||
if row["upstreamActivationTime"] != "2026-07-10 10:00:00" {
|
||||
t.Fatalf("activation time = %q", row["upstreamActivationTime"])
|
||||
}
|
||||
if row["insertToPlugEnabled"] != "true" || row["carrierTrafficCutoffEnabled"] != "false" {
|
||||
t.Fatalf("boolean defaults not applied: %#v", row)
|
||||
}
|
||||
if row["agreementExpiryDate"] != "2027-07-10" {
|
||||
t.Fatalf("agreement expiry = %q", row["agreementExpiryDate"])
|
||||
}
|
||||
if row["hasTestPeriod"] != "true" || row["testPeriodDays"] != "30" || row["testPeriodLatestExpiryDate"] != "2026-08-09" {
|
||||
t.Fatalf("test period defaults not applied: %#v", row)
|
||||
}
|
||||
if row["hasSilentPeriod"] != "false" || row["silentPeriodDays"] != "" || row["silentPeriodLatestExpiryDate"] != "" {
|
||||
t.Fatalf("silent period defaults not applied: %#v", row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Service) Health(w http.ResponseWriter, r *http.Request) { s.health(w, r) }
|
||||
func (s *Service) AdminState(w http.ResponseWriter, r *http.Request) { s.adminState(w, r) }
|
||||
func (s *Service) AdminAppIDs(w http.ResponseWriter, r *http.Request) { s.adminAppIDs(w, r) }
|
||||
func (s *Service) AdminCards(w http.ResponseWriter, r *http.Request) { s.adminCards(w, r) }
|
||||
func (s *Service) AdminCardGroups(w http.ResponseWriter, r *http.Request) { s.adminCardGroups(w, r) }
|
||||
func (s *Service) AdminCardsExport(w http.ResponseWriter, r *http.Request) { s.adminCardsExport(w, r) }
|
||||
func (s *Service) AdminSettings(w http.ResponseWriter, r *http.Request) { s.adminSettings(w, r) }
|
||||
func (s *Service) AdminOrders(w http.ResponseWriter, r *http.Request) { s.adminOrders(w, r) }
|
||||
func (s *Service) AdminOperationLogs(w http.ResponseWriter, r *http.Request) {
|
||||
s.adminOperationLogs(w, r)
|
||||
}
|
||||
func (s *Service) AdminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
|
||||
s.adminRuntimeLogs(w, r)
|
||||
}
|
||||
func (s *Service) AdminSync(w http.ResponseWriter, r *http.Request) { s.adminSync(w, r) }
|
||||
func (s *Service) AdminGenerate(w http.ResponseWriter, r *http.Request) { s.adminGenerate(w, r) }
|
||||
func (s *Service) OneLink(w http.ResponseWriter, r *http.Request) { s.oneLink(w, r) }
|
||||
func (s *Service) Chan(w http.ResponseWriter, r *http.Request) { s.chanAPI(w, r) }
|
||||
|
||||
func (s *Service) WithOperationLogging(next http.Handler) http.Handler {
|
||||
return s.withOperationLogging(next)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []interface{} `json:"result"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
AppIDs []Credential `json:"appids"`
|
||||
Groups []CardGroup `json:"groups"`
|
||||
Cards []SIMCard `json:"cards"`
|
||||
Tokens map[string]Token `json:"tokens"`
|
||||
Orders []Order `json:"orders"`
|
||||
Settings SimulationSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type CardGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CardCount int `json:"cardCount"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
AppID string `json:"appid"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastToken string `json:"lastToken,omitempty"`
|
||||
TokenExpires string `json:"tokenExpires,omitempty"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AppID string `json:"appid"`
|
||||
Value string `json:"value"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type APN struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
DiagnosisStatus string `json:"diagnosisStatus,omitempty"`
|
||||
UsageBytes float64 `json:"usageBytes"`
|
||||
ModifyTime string `json:"modifyTime,omitempty"`
|
||||
OverUseDetails string `json:"overUseDetails,omitempty"`
|
||||
ServiceUsageState string `json:"serviceUsageState,omitempty"`
|
||||
PCCs []PCC `json:"pccs,omitempty"`
|
||||
}
|
||||
|
||||
type PCC struct {
|
||||
ServiceCode string `json:"serviceCode"`
|
||||
Name string `json:"name"`
|
||||
PolicyStatus string `json:"policyStatus"`
|
||||
ServiceUsageState string `json:"serviceUsageState"`
|
||||
ModifyTime string `json:"modifyTime"`
|
||||
}
|
||||
|
||||
type SIMCard struct {
|
||||
GroupID int64 `json:"groupId,omitempty"`
|
||||
GroupName string `json:"groupName,omitempty"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
IMEI string `json:"imei"`
|
||||
Remark string `json:"remark"`
|
||||
CardStatus string `json:"cardStatus"`
|
||||
LastChangeDate string `json:"lastChangeDate"`
|
||||
StopReason string `json:"stopReason,omitempty"`
|
||||
FrozenStatus string `json:"frozenStatus,omitempty"`
|
||||
FrozenReason string `json:"frozenReason,omitempty"`
|
||||
FrozenOperTime string `json:"frozenOperTime,omitempty"`
|
||||
OpenDate string `json:"openDate"`
|
||||
ActiveDate string `json:"activeDate"`
|
||||
OnOffStatus string `json:"onOffStatus"`
|
||||
SessionStatus string `json:"sessionStatus"`
|
||||
SessionCreateDate string `json:"sessionCreateDate,omitempty"`
|
||||
IP string `json:"ip"`
|
||||
IPv6Prefix string `json:"ipv6Prefix"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
RAT string `json:"rat"`
|
||||
CardBindStatus string `json:"cardBindStatus"`
|
||||
RegionLimitStatus string `json:"regionLimitStatus"`
|
||||
AreaRestrictType string `json:"areaRestrictType"`
|
||||
RestrictedArea string `json:"restrictedArea"`
|
||||
MonthUsageKB float64 `json:"monthUsageKB"`
|
||||
PoolUsageBytes float64 `json:"poolUsageBytes"`
|
||||
DailyBaseKB float64 `json:"dailyBaseKB"`
|
||||
MonthlyBaseKB float64 `json:"monthlyBaseKB"`
|
||||
GrowthPerMinuteKB float64 `json:"growthPerMinuteKB"`
|
||||
APNs []APN `json:"apns"`
|
||||
LastUsageUpdateTime string `json:"lastUsageUpdateTime"`
|
||||
Exported bool `json:"exported"`
|
||||
ExportCheckedAt string `json:"exportCheckedAt"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
OrderNum string `json:"orderNum"`
|
||||
MSISDN string `json:"msisdn,omitempty"`
|
||||
ICCID string `json:"iccid,omitempty"`
|
||||
IMSI string `json:"imsi,omitempty"`
|
||||
Type string `json:"type"`
|
||||
OperType string `json:"operType,omitempty"`
|
||||
APNName string `json:"apnName,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SimulationSettings struct {
|
||||
AutoGrowthEnabled bool `json:"autoGrowthEnabled"`
|
||||
DefaultGrowthKB float64 `json:"defaultGrowthKB"`
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Scanned int `json:"scanned"`
|
||||
Synced int `json:"synced"`
|
||||
}
|
||||
|
||||
type GenerateResult struct {
|
||||
Generated int `json:"generated"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type CardPage struct {
|
||||
Items []SIMCard `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type OrderPage struct {
|
||||
Items []Order `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxOperationDetailBytes = 2048
|
||||
maxLoggedRequestBytes = 2 << 20
|
||||
)
|
||||
|
||||
type OperationLog struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Params string `json:"params,omitempty"`
|
||||
Result string `json:"result"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Message string `json:"message,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
ClientIP string `json:"clientIp,omitempty"`
|
||||
}
|
||||
|
||||
type OperationLogPage struct {
|
||||
Items []OperationLog `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type OperationLogStore struct {
|
||||
mu sync.RWMutex
|
||||
logs []OperationLog
|
||||
nextID int64
|
||||
now func() time.Time
|
||||
day string
|
||||
}
|
||||
|
||||
func NewOperationLogStore() *OperationLogStore {
|
||||
store := newOperationLogStore(time.Now)
|
||||
store.scheduleMidnightClear()
|
||||
return store
|
||||
}
|
||||
|
||||
func newOperationLogStore(now func() time.Time) *OperationLogStore {
|
||||
return &OperationLogStore{logs: make([]OperationLog, 0), now: now, day: logDay(now())}
|
||||
}
|
||||
|
||||
func (s *OperationLogStore) Add(log OperationLog) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clearExpiredLocked()
|
||||
s.nextID++
|
||||
log.ID = s.nextID
|
||||
log.CreatedAt = s.now().Format("2006-01-02 15:04:05")
|
||||
s.logs = append(s.logs, log)
|
||||
}
|
||||
|
||||
func (s *OperationLogStore) Query(keyword, category, result string, page, pageSize int) OperationLogPage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clearExpiredLocked()
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
matched := make([]OperationLog, 0, len(s.logs))
|
||||
for i := len(s.logs) - 1; i >= 0; i-- {
|
||||
item := s.logs[i]
|
||||
if category != "" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
if result != "" && item.Result != result {
|
||||
continue
|
||||
}
|
||||
searchable := strings.ToLower(item.Action + " " + item.Target + " " + item.Path + " " + item.Params + " " + item.Message + " " + item.ClientIP)
|
||||
if keyword != "" && !strings.Contains(searchable, keyword) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, item)
|
||||
}
|
||||
total := len(matched)
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := append([]OperationLog(nil), matched[start:end]...)
|
||||
return OperationLogPage{Items: items, Total: total, Page: page, PageSize: pageSize}
|
||||
}
|
||||
|
||||
func (s *OperationLogStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = s.logs[:0]
|
||||
s.day = logDay(s.now())
|
||||
}
|
||||
|
||||
func (s *OperationLogStore) clearExpiredLocked() {
|
||||
today := logDay(s.now())
|
||||
if s.day == today {
|
||||
return
|
||||
}
|
||||
s.logs = s.logs[:0]
|
||||
s.day = today
|
||||
}
|
||||
|
||||
func (s *OperationLogStore) scheduleMidnightClear() {
|
||||
now := s.now()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
time.AfterFunc(time.Until(next), func() {
|
||||
s.Clear()
|
||||
s.scheduleMidnightClear()
|
||||
})
|
||||
}
|
||||
|
||||
func logDay(value time.Time) string {
|
||||
return value.Format("2006-01-02")
|
||||
}
|
||||
|
||||
type operationResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *operationResponseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *operationResponseWriter) Write(p []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
if w.body.Len() < maxOperationDetailBytes {
|
||||
remaining := maxOperationDetailBytes - w.body.Len()
|
||||
if len(p) > remaining {
|
||||
_, _ = w.body.Write(p[:remaining])
|
||||
} else {
|
||||
_, _ = w.body.Write(p)
|
||||
}
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
func (w *operationResponseWriter) Flush() {
|
||||
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
type replayReadCloser struct {
|
||||
io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (r *replayReadCloser) Close() error {
|
||||
return r.closer.Close()
|
||||
}
|
||||
|
||||
func (s *Service) withOperationLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !shouldLogOperation(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
body := captureRequestBody(r)
|
||||
category, action := s.describeOperation(r, body)
|
||||
started := time.Now()
|
||||
wrapped := &operationResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
if wrapped.status == 0 {
|
||||
wrapped.status = http.StatusOK
|
||||
}
|
||||
result, message := operationResult(wrapped.status, wrapped.body.Bytes())
|
||||
s.operationLogs.Add(OperationLog{
|
||||
Category: category, Action: action, Target: operationTarget(r.URL.Query(), body),
|
||||
Method: r.Method, Path: r.URL.Path, Params: operationParams(r.URL.Query(), body),
|
||||
Result: result, StatusCode: wrapped.status, Message: message,
|
||||
DurationMS: time.Since(started).Milliseconds(), ClientIP: clientIP(r),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func shouldLogOperation(path string) bool {
|
||||
if path == "/api/operation-logs" || path == "/api/runtime-logs" || path == "/healthz" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v5/") || strings.HasPrefix(path, "/ec/") || strings.HasPrefix(path, "/sim/")
|
||||
}
|
||||
|
||||
func captureRequestBody(r *http.Request) []byte {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
original := r.Body
|
||||
raw, err := io.ReadAll(io.LimitReader(original, maxLoggedRequestBytes+1))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
r.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), closer: original}
|
||||
if len(raw) > maxLoggedRequestBytes {
|
||||
return raw[:maxLoggedRequestBytes]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (s *Service) describeOperation(r *http.Request, body []byte) (string, string) {
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return "管理台", s.describeAdminOperation(r, body)
|
||||
}
|
||||
category := "OneLink"
|
||||
endpoint := strings.TrimPrefix(strings.TrimPrefix(path, "/v5/"), "/")
|
||||
if strings.HasPrefix(path, "/sim/") {
|
||||
category = "Chan"
|
||||
endpoint = strings.TrimPrefix(path, "/sim/")
|
||||
}
|
||||
actions := map[string]string{
|
||||
"ec/get/token": "获取 Token", "ec/query/sim-basic-info": "查询卡基础信息",
|
||||
"ec/query/sim-session": "查询在线状态", "ec/query/sim-status": "查询卡状态",
|
||||
"ec/query/sim-imei": "查询 IMEI", "ec/query/sim-data-usage-inpool": "查询流量池用量",
|
||||
"ec/query/sim-data-usage": "查询当月流量", "ec/query/sim-data-usage-monthly/batch": "批量查询月流量",
|
||||
"ec/query/sim-data-usage-daily/batch": "批量查询日流量", "ec/query/apn-info": "查询 APN",
|
||||
"ec/query/card-bind-status": "查询绑定状态", "ec/query/on-off-status": "查询开关机状态",
|
||||
"ec/query/region-limit-status": "查询区域限制", "ec/change/sim-status": "变更 SIM 状态",
|
||||
"ec/operate/sim-apn-function": "操作 APN",
|
||||
"query/sim-basic-info": "查询卡基础信息", "query/sim-session": "查询在线状态",
|
||||
"query/sim-status": "查询卡状态", "query/sim-status-batch": "批量查询卡状态",
|
||||
"query/sim-card-bind-status": "查询绑定状态", "query/sim-device-status": "查询设备状态",
|
||||
"query/sim-region-limit-status": "查询区域限制", "query/sim-imei": "查询 IMEI",
|
||||
"query/sim-datausage": "查询当月流量", "query/sim-datausage-monthly": "查询月流量",
|
||||
"query/sim-datausage-monthly-batch": "批量查询月流量", "query/sim-datausage-daily": "查询日流量",
|
||||
"query/sim-datausage-daily-batch": "批量查询日流量", "query/sim-apn-info": "查询 APN",
|
||||
"change/sim-status": "变更 SIM 状态", "change/sim-apn": "操作 APN",
|
||||
}
|
||||
if action := actions[endpoint]; action != "" {
|
||||
return category, action
|
||||
}
|
||||
return category, "访问 " + endpoint
|
||||
}
|
||||
|
||||
func (s *Service) describeAdminOperation(r *http.Request, body []byte) string {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
actions := map[string]string{
|
||||
"GET /api/state": "查看管理台", "GET /api/card-groups": "查询卡分组",
|
||||
"POST /api/card-groups": "新增卡分组", "DELETE /api/card-groups": "删除卡分组",
|
||||
"GET /api/cards": "查询 SIM 卡", "DELETE /api/cards": "删除 SIM 卡",
|
||||
"GET /api/cards/export": "导出 SIM 卡", "POST /api/settings": "更新模拟设置",
|
||||
"GET /api/orders": "查询订单", "DELETE /api/orders": "清空订单",
|
||||
"POST /api/generate": "一键生成 SIM 卡", "POST /api/sync": "同步生成 SIM 卡",
|
||||
"DELETE /api/appids": "删除 AppID",
|
||||
}
|
||||
if key == "POST /api/appids" {
|
||||
appid := jsonStringField(body, "appid")
|
||||
for _, item := range s.store.Snapshot().AppIDs {
|
||||
if item.AppID == appid {
|
||||
return "更新 AppID"
|
||||
}
|
||||
}
|
||||
return "新增 AppID"
|
||||
}
|
||||
if key == "POST /api/cards" {
|
||||
msisdn := jsonStringField(body, "msisdn")
|
||||
for _, item := range s.store.Snapshot().Cards {
|
||||
if item.MSISDN == msisdn {
|
||||
return "编辑 SIM 卡"
|
||||
}
|
||||
}
|
||||
return "新增 SIM 卡"
|
||||
}
|
||||
if action := actions[key]; action != "" {
|
||||
return action
|
||||
}
|
||||
return "访问 " + r.URL.Path
|
||||
}
|
||||
|
||||
func jsonStringField(body []byte, key string) string {
|
||||
var value map[string]interface{}
|
||||
if json.Unmarshal(body, &value) != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value[key])
|
||||
}
|
||||
|
||||
func operationTarget(query url.Values, body []byte) string {
|
||||
keys := []string{"appid", "msisdn", "iccid", "imsi", "id"}
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(query.Get(key)); value != "" {
|
||||
return key + ": " + value
|
||||
}
|
||||
}
|
||||
var value map[string]interface{}
|
||||
if json.Unmarshal(body, &value) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if item := strings.TrimSpace(fmt.Sprint(value[key])); item != "" && item != "<nil>" {
|
||||
return key + ": " + item
|
||||
}
|
||||
}
|
||||
if simKeys, ok := value["simKeys"].([]interface{}); ok && len(simKeys) > 0 {
|
||||
if first, ok := simKeys[0].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"msisdn", "iccid", "imsi"} {
|
||||
if item := strings.TrimSpace(fmt.Sprint(first[key])); item != "" && item != "<nil>" {
|
||||
return key + ": " + item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func operationParams(query url.Values, body []byte) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if len(query) > 0 {
|
||||
clean := make(url.Values, len(query))
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if sensitiveOperationKey(key) {
|
||||
clean.Set(key, "***")
|
||||
} else {
|
||||
clean[key] = append([]string(nil), query[key]...)
|
||||
}
|
||||
}
|
||||
parts = append(parts, clean.Encode())
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
var value interface{}
|
||||
if json.Unmarshal(body, &value) == nil {
|
||||
if raw, err := json.Marshal(redactOperationValue(value)); err == nil {
|
||||
parts = append(parts, string(raw))
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, "[non-JSON request body]")
|
||||
}
|
||||
}
|
||||
result := strings.Join(parts, " ")
|
||||
if len(result) > maxOperationDetailBytes {
|
||||
return result[:maxOperationDetailBytes] + "..."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func redactOperationValue(value interface{}) interface{} {
|
||||
switch item := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, child := range item {
|
||||
if sensitiveOperationKey(key) {
|
||||
item[key] = "***"
|
||||
} else {
|
||||
item[key] = redactOperationValue(child)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for i := range item {
|
||||
item[i] = redactOperationValue(item[i])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sensitiveOperationKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
return strings.Contains(key, "password") || strings.Contains(key, "token") || strings.Contains(key, "authorization")
|
||||
}
|
||||
|
||||
func operationResult(status int, body []byte) (string, string) {
|
||||
if status >= http.StatusBadRequest {
|
||||
return "failed", responseMessage(body, http.StatusText(status))
|
||||
}
|
||||
var value interface{}
|
||||
if json.Unmarshal(body, &value) != nil {
|
||||
return "success", ""
|
||||
}
|
||||
if item, ok := value.(map[string]interface{}); ok {
|
||||
if message, ok := item["error"].(string); ok && message != "" {
|
||||
return "failed", message
|
||||
}
|
||||
if code := fmt.Sprint(item["status"]); code != "<nil>" && code != "" && code != "0" {
|
||||
return "failed", fmt.Sprint(item["message"])
|
||||
}
|
||||
}
|
||||
if items, ok := value.([]interface{}); ok {
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if code, ok := item["code"].(float64); ok && code != chanSuccessCode {
|
||||
return "failed", fmt.Sprint(item["message"])
|
||||
}
|
||||
}
|
||||
}
|
||||
return "success", ""
|
||||
}
|
||||
|
||||
func responseMessage(body []byte, fallback string) string {
|
||||
var value map[string]interface{}
|
||||
if json.Unmarshal(body, &value) == nil {
|
||||
for _, key := range []string{"error", "message"} {
|
||||
if message := strings.TrimSpace(fmt.Sprint(value[key])); message != "" && message != "<nil>" {
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
|
||||
return forwarded
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func (s *Service) adminOperationLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
page, pageSize := pagination(r)
|
||||
writeJSON(w, http.StatusOK, s.operationLogs.Query(
|
||||
r.URL.Query().Get("keyword"), r.URL.Query().Get("category"), r.URL.Query().Get("result"), page, pageSize,
|
||||
))
|
||||
}
|
||||
|
||||
func operationLogID(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOperationLogCapturesAdminMutationAndRedactsSecrets(t *testing.T) {
|
||||
service, err := NewService(t.TempDir()+"/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/appids", strings.NewReader(`{"appid":"demo","password":"plain-secret","name":"演示"}`))
|
||||
req.RemoteAddr = "192.0.2.10:1234"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
|
||||
}
|
||||
page := service.operationLogs.Query("", "管理台", "success", 1, 20)
|
||||
if page.Total != 1 || page.Items[0].Action != "新增 AppID" {
|
||||
t.Fatalf("logs = %#v", page.Items)
|
||||
}
|
||||
if strings.Contains(page.Items[0].Params, "plain-secret") || !strings.Contains(page.Items[0].Params, "***") {
|
||||
t.Fatalf("secret was not redacted: %s", page.Items[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationLogQueryReturnsNewestFirst(t *testing.T) {
|
||||
store := newOperationLogStore(func() time.Time { return time.Date(2026, 8, 13, 10, 0, 0, 0, time.Local) })
|
||||
store.Add(OperationLog{Category: "管理台", Action: "第一条", Result: "success"})
|
||||
store.Add(OperationLog{Category: "管理台", Action: "第二条", Result: "success"})
|
||||
page := store.Query("", "", "", 1, 1)
|
||||
if page.Total != 2 || len(page.Items) != 1 || page.Items[0].Action != "第二条" {
|
||||
t.Fatalf("page = %#v", page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultRuntimeLogCapacity = 500
|
||||
|
||||
type RuntimeLog struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RuntimeLogResult struct {
|
||||
Items []RuntimeLog `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type RuntimeLogStore struct {
|
||||
mu sync.Mutex
|
||||
logs []RuntimeLog
|
||||
partial []byte
|
||||
nextID int64
|
||||
capacity int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRuntimeLogStore(capacities ...int) *RuntimeLogStore {
|
||||
capacity := defaultRuntimeLogCapacity
|
||||
if len(capacities) > 0 {
|
||||
capacity = capacities[0]
|
||||
}
|
||||
return newRuntimeLogStore(capacity, time.Now)
|
||||
}
|
||||
|
||||
func newRuntimeLogStore(capacity int, now func() time.Time) *RuntimeLogStore {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
return &RuntimeLogStore{
|
||||
logs: make([]RuntimeLog, 0, capacity),
|
||||
capacity: capacity,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RuntimeLogStore) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
written := len(p)
|
||||
s.partial = append(s.partial, p...)
|
||||
for {
|
||||
index := bytes.IndexByte(s.partial, '\n')
|
||||
if index < 0 {
|
||||
break
|
||||
}
|
||||
s.addLineLocked(string(bytes.TrimSuffix(s.partial[:index], []byte{'\r'})))
|
||||
s.partial = append(s.partial[:0], s.partial[index+1:]...)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (s *RuntimeLogStore) Latest(limit int) RuntimeLogResult {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit < 1 || limit > s.capacity {
|
||||
limit = s.capacity
|
||||
}
|
||||
start := len(s.logs) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
items := make([]RuntimeLog, len(s.logs)-start)
|
||||
for i := range items {
|
||||
items[i] = s.logs[len(s.logs)-1-i]
|
||||
}
|
||||
return RuntimeLogResult{Items: items, Total: len(s.logs)}
|
||||
}
|
||||
|
||||
func (s *RuntimeLogStore) addLineLocked(message string) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
s.nextID++
|
||||
item := RuntimeLog{
|
||||
ID: s.nextID,
|
||||
CreatedAt: s.now().Format("2006-01-02 15:04:05"),
|
||||
Level: runtimeLogLevel(message),
|
||||
Message: message,
|
||||
}
|
||||
if len(s.logs) == s.capacity {
|
||||
copy(s.logs, s.logs[1:])
|
||||
s.logs[len(s.logs)-1] = item
|
||||
return
|
||||
}
|
||||
s.logs = append(s.logs, item)
|
||||
}
|
||||
|
||||
func runtimeLogLevel(message string) string {
|
||||
upper := strings.ToUpper(message)
|
||||
switch {
|
||||
case strings.Contains(upper, "FATAL"), strings.Contains(upper, "PANIC"), strings.Contains(upper, "ERROR"), strings.Contains(upper, "[ERRO]"):
|
||||
return "ERROR"
|
||||
case strings.Contains(upper, "WARN"), strings.Contains(upper, "[WARN]"):
|
||||
return "WARN"
|
||||
case strings.Contains(upper, "DEBUG"), strings.Contains(upper, "[DEBU]"):
|
||||
return "DEBUG"
|
||||
default:
|
||||
return "INFO"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
writeJSON(w, http.StatusOK, s.runtimeLogs.Latest(limit))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRuntimeLogStoreRetainsLatestLines(t *testing.T) {
|
||||
now := func() time.Time { return time.Date(2026, 8, 13, 12, 30, 0, 0, time.Local) }
|
||||
store := newRuntimeLogStore(2, now)
|
||||
if _, err := store.Write([]byte("first\nsecond")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Write([]byte(" line\nERROR third\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := store.Latest(10)
|
||||
if result.Total != 2 || len(result.Items) != 2 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if result.Items[0].Message != "ERROR third" || result.Items[0].Level != "ERROR" {
|
||||
t.Fatalf("newest = %#v", result.Items[0])
|
||||
}
|
||||
if result.Items[1].Message != "second line" {
|
||||
t.Fatalf("second = %#v", result.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLogsEndpointIsNotRecordedAsOperation(t *testing.T) {
|
||||
runtimeLogs := NewRuntimeLogStore()
|
||||
service, err := NewService("sqlite::@file(:memory:)", runtimeLogs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = runtimeLogs.Write([]byte("service ready\n"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/runtime-logs?limit=10", nil)
|
||||
res := httptest.NewRecorder()
|
||||
service.Router().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
|
||||
}
|
||||
var result RuntimeLogResult
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].Message != "service ready" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if page := service.operationLogs.Query("", "", "", 1, 20); page.Total != 0 {
|
||||
t.Fatalf("runtime log polling was recorded: %#v", page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *StoreManager
|
||||
operationLogs *OperationLogStore
|
||||
runtimeLogs *RuntimeLogStore
|
||||
}
|
||||
|
||||
func NewService(dataPath string, runtimeLogStores ...*RuntimeLogStore) (*Service, error) {
|
||||
store, err := NewStoreManager(dataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeLogs := NewRuntimeLogStore()
|
||||
if len(runtimeLogStores) > 0 && runtimeLogStores[0] != nil {
|
||||
runtimeLogs = runtimeLogStores[0]
|
||||
}
|
||||
return &Service{store: store, operationLogs: NewOperationLogStore(), runtimeLogs: runtimeLogs}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Router() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.health)
|
||||
mux.HandleFunc("/api/state", s.adminState)
|
||||
mux.HandleFunc("/api/appids", s.adminAppIDs)
|
||||
mux.HandleFunc("/api/cards", s.adminCards)
|
||||
mux.HandleFunc("/api/card-groups", s.adminCardGroups)
|
||||
mux.HandleFunc("/api/cards/export", s.adminCardsExport)
|
||||
mux.HandleFunc("/api/settings", s.adminSettings)
|
||||
mux.HandleFunc("/api/orders", s.adminOrders)
|
||||
mux.HandleFunc("/api/operation-logs", s.adminOperationLogs)
|
||||
mux.HandleFunc("/api/runtime-logs", s.adminRuntimeLogs)
|
||||
mux.HandleFunc("/api/sync", s.adminSync)
|
||||
mux.HandleFunc("/api/generate", s.adminGenerate)
|
||||
mux.HandleFunc("/sim/", s.chanAPI)
|
||||
mux.HandleFunc("/v5/", s.oneLink)
|
||||
mux.HandleFunc("/ec/", s.oneLink)
|
||||
return withCORS(s.withOperationLogging(mux))
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Service) oneLink(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v5/")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
switch path {
|
||||
case "ec/get/token":
|
||||
s.getToken(w, r)
|
||||
case "ec/query/sim-basic-info":
|
||||
s.withToken(w, r, s.queryBasicInfo)
|
||||
case "ec/query/sim-session":
|
||||
s.withToken(w, r, s.querySession)
|
||||
case "ec/query/sim-status":
|
||||
s.withToken(w, r, s.queryStatus)
|
||||
case "ec/query/sim-stop-reason":
|
||||
s.withToken(w, r, s.queryStopReason)
|
||||
case "ec/query/sim-frozen-state-info":
|
||||
s.withToken(w, r, s.queryFrozenState)
|
||||
case "ec/query/sim-apn-session-info":
|
||||
s.withToken(w, r, s.queryAPNSession)
|
||||
case "ec/query/sim-activated-features-info":
|
||||
s.withToken(w, r, s.queryActivatedFeatures)
|
||||
case "ec/query/sim-operating-status":
|
||||
s.withToken(w, r, s.queryOperatingStatus)
|
||||
case "ec/query/sim-data-diagnosis":
|
||||
s.withToken(w, r, s.queryDataDiagnosis)
|
||||
case "ec/query/sim-imei":
|
||||
s.withToken(w, r, s.queryIMEI)
|
||||
case "ec/query/sim-data-usage-inpool":
|
||||
s.withToken(w, r, s.queryDataUsageInPool)
|
||||
case "ec/query/sim-data-usage":
|
||||
s.withToken(w, r, s.queryDataUsage)
|
||||
case "ec/query/sim-data-usage-monthly/batch":
|
||||
s.withToken(w, r, s.queryDataUsageMonthlyBatch)
|
||||
case "ec/query/sim-data-usage-daily/batch":
|
||||
s.withToken(w, r, s.queryDataUsageDailyBatch)
|
||||
case "ec/query/apn-info":
|
||||
s.withToken(w, r, s.queryAPNInfo)
|
||||
case "ec/query/card-bind-status":
|
||||
s.withToken(w, r, s.queryCardBindStatus)
|
||||
case "ec/query/on-off-status":
|
||||
s.withToken(w, r, s.queryOnOffStatus)
|
||||
case "ec/query/region-limit-status":
|
||||
s.withToken(w, r, s.queryRegionLimitStatus)
|
||||
case "ec/change/sim-status":
|
||||
s.withToken(w, r, s.changeSIMStatus)
|
||||
case "ec/operate/sim-apn-function":
|
||||
s.withToken(w, r, s.operateSIMAPNFunction)
|
||||
default:
|
||||
writeOneLink(w, Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) getToken(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
appid := q.Get("appid")
|
||||
password := q.Get("password")
|
||||
refresh := q.Get("refresh")
|
||||
var result Response
|
||||
|
||||
err := s.store.SaveMutation(func(st *Store) error {
|
||||
for i := range st.AppIDs {
|
||||
cred := &st.AppIDs[i]
|
||||
if cred.AppID != appid || !cred.Enabled {
|
||||
continue
|
||||
}
|
||||
if cred.Password != password {
|
||||
result = Response{Status: "12022", Message: "PASSWORD 鉴权不通过", Result: []interface{}{}}
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if refresh == "0" {
|
||||
if token, ok := st.Tokens[appid]; ok && token.ExpiresAt.After(now) {
|
||||
ttl := int(time.Until(token.ExpiresAt).Seconds())
|
||||
result = okResp(map[string]string{"token": token.Value, "ttl": strconv.Itoa(ttl)})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
token := Token{
|
||||
AppID: appid,
|
||||
Value: randomHex(32),
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
st.Tokens[appid] = token
|
||||
cred.LastToken = token.Value
|
||||
cred.TokenExpires = token.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
result = okResp(map[string]string{"token": token.Value, "ttl": "3600"})
|
||||
return nil
|
||||
}
|
||||
result = Response{Status: "12021", Message: "认证无效", Result: []interface{}{}}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOneLink(w, Response{Status: "19000", Message: err.Error(), Result: []interface{}{}})
|
||||
return
|
||||
}
|
||||
writeOneLink(w, result)
|
||||
}
|
||||
|
||||
func (s *Service) withToken(w http.ResponseWriter, r *http.Request, next func(http.ResponseWriter, *http.Request, Store)) {
|
||||
token := r.URL.Query().Get("token")
|
||||
st := s.store.Snapshot()
|
||||
for _, t := range st.Tokens {
|
||||
if t.Value == token && t.ExpiresAt.After(time.Now()) {
|
||||
next(w, r, st)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeOneLink(w, Response{Status: "12021", Message: "TOKEN 不存在或已过期,请重新获取", Result: []interface{}{}})
|
||||
}
|
||||
|
||||
func (s *Service) queryBasicInfo(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{
|
||||
"msisdn": card.MSISDN, "imsi": card.IMSI, "iccid": card.ICCID,
|
||||
"activeDate": card.ActiveDate, "openDate": card.OpenDate, "remark": card.Remark,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Service) querySession(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{
|
||||
"simSessionList": []map[string]string{{
|
||||
"apnId": "CMIOT", "status": card.SessionStatus, "ip": card.IP,
|
||||
"ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6,
|
||||
"createDate": card.LastChangeDate, "rat": card.RAT,
|
||||
}},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Service) queryStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"cardStatus": card.CardStatus, "lastChangeDate": card.LastChangeDate}))
|
||||
}
|
||||
|
||||
func (s *Service) queryStopReason(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
stopReason := card.StopReason
|
||||
if stopReason == "" {
|
||||
stopReason = defaultStopReason(card.CardStatus)
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"platformType": "OneLink-CT", "stopReason": stopReason}))
|
||||
}
|
||||
|
||||
func (s *Service) queryFrozenState(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
frozen := card.FrozenStatus
|
||||
if frozen == "" {
|
||||
frozen = "0"
|
||||
}
|
||||
operTime := card.FrozenOperTime
|
||||
if operTime == "" {
|
||||
operTime = diagnosticTime(parseCardTime(card.LastChangeDate))
|
||||
}
|
||||
data := map[string]string{"frozenStatus": frozen, "operTime": operTime}
|
||||
if frozen == "1" && card.FrozenReason != "" {
|
||||
data["frozenReason"] = card.FrozenReason
|
||||
}
|
||||
writeOneLink(w, okResp(data))
|
||||
}
|
||||
|
||||
func (s *Service) queryAPNSession(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
items := make([]map[string]string, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
status := card.SessionStatus
|
||||
if status != "00" && status != "01" {
|
||||
status = "00"
|
||||
}
|
||||
createTime := card.SessionCreateDate
|
||||
if createTime == "" {
|
||||
createTime = card.LastChangeDate
|
||||
}
|
||||
items = append(items, map[string]string{"apn": apn.Name, "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
status := card.SessionStatus
|
||||
if status != "00" && status != "01" {
|
||||
status = "00"
|
||||
}
|
||||
createTime := card.SessionCreateDate
|
||||
if createTime == "" {
|
||||
createTime = card.LastChangeDate
|
||||
}
|
||||
items = append(items, map[string]string{"apn": "CMIOT", "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"infoList": items}))
|
||||
}
|
||||
|
||||
func (s *Service) queryActivatedFeatures(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
items := make([]map[string]string, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
openStatus := "0"
|
||||
if apn.Status == "1" {
|
||||
openStatus = "1"
|
||||
}
|
||||
changeTime := apn.ModifyTime
|
||||
if changeTime == "" {
|
||||
changeTime = diagnosticTime(parseCardTime(card.LastChangeDate))
|
||||
}
|
||||
items = append(items, map[string]string{"functionType": "11", "openStatus": openStatus, "apn": apn.Name, "changeTime": changeTime})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, map[string]string{"functionType": "11", "openStatus": "1", "apn": "CMIOT", "changeTime": diagnosticTime(parseCardTime(card.LastChangeDate))})
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"communicationFunctionList": items}))
|
||||
}
|
||||
|
||||
func (s *Service) queryOperatingStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"operatingStatus": card.OnOffStatus}))
|
||||
}
|
||||
|
||||
func (s *Service) queryDataDiagnosis(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
apnInfo := make([]map[string]interface{}, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
status := apn.DiagnosisStatus
|
||||
if status == "" {
|
||||
status = "2"
|
||||
}
|
||||
modifyTime := apn.ModifyTime
|
||||
if modifyTime == "" {
|
||||
modifyTime = diagnosticTime(parseCardTime(card.LastChangeDate))
|
||||
}
|
||||
usageState := apn.ServiceUsageState
|
||||
if usageState == "" {
|
||||
usageState = "1"
|
||||
}
|
||||
item := map[string]interface{}{"apnName": apn.Name, "apnStatus": status, "modifyTime": modifyTime, "apnServiceUsageState": usageState}
|
||||
if status == "4" && apn.OverUseDetails != "" {
|
||||
item["overUseDetails"] = apn.OverUseDetails
|
||||
}
|
||||
pccInfo := make([]map[string]string, 0, len(apn.PCCs))
|
||||
for _, pcc := range apn.PCCs {
|
||||
pccTime := pcc.ModifyTime
|
||||
if pccTime == "" {
|
||||
pccTime = modifyTime
|
||||
}
|
||||
pccInfo = append(pccInfo, map[string]string{"pccServiceCode": pcc.ServiceCode, "pccName": pcc.Name, "pccPolicyStatus": pcc.PolicyStatus, "pccServiceUsageState": pcc.ServiceUsageState, "modifyTime": pccTime})
|
||||
}
|
||||
item["pccList"] = map[string]interface{}{"pccInfo": pccInfo}
|
||||
apnInfo = append(apnInfo, item)
|
||||
}
|
||||
if len(apnInfo) == 0 {
|
||||
apnInfo = append(apnInfo, map[string]interface{}{"apnName": "CMIOT", "apnStatus": "2", "modifyTime": diagnosticTime(parseCardTime(card.LastChangeDate)), "apnServiceUsageState": "1", "pccList": map[string]interface{}{"pccInfo": []map[string]string{}}})
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"apnList": map[string]interface{}{"apnInfo": apnInfo}}))
|
||||
}
|
||||
|
||||
func accessType(rat string) string {
|
||||
switch strings.ToUpper(rat) {
|
||||
case "5G", "51", "SA":
|
||||
return "51"
|
||||
case "NSA", "10":
|
||||
return "10"
|
||||
case "REDCAP", "58":
|
||||
return "58"
|
||||
case "NB", "8":
|
||||
return "8"
|
||||
case "2G", "2":
|
||||
return "2"
|
||||
case "3G", "3":
|
||||
return "1"
|
||||
default:
|
||||
return "6"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) queryIMEI(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"imei": card.IMEI}))
|
||||
}
|
||||
|
||||
func (s *Service) queryDataUsageInPool(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
apns := make([]map[string]string, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
apns = append(apns, map[string]string{"apnName": apn.Name, "apnUseAmount": fmtAmount(apn.UsageBytes)})
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
|
||||
}
|
||||
|
||||
func (s *Service) queryDataUsage(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{
|
||||
"dataAmount": fmt.Sprintf("%.2f", card.MonthUsageKB),
|
||||
"apnUseAmountList": apnUseAmountList(card),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Service) queryDataUsageMonthlyBatch(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
cards, key, err := findBatchCards(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
amount := card.MonthUsageKB
|
||||
if q := r.URL.Query().Get("queryDate"); len(q) == 6 && q != time.Now().Format("200601") {
|
||||
amount = card.MonthlyBaseKB
|
||||
}
|
||||
items = append(items, batchUsageItem(card, key, amount))
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{
|
||||
"deadLine": time.Now().AddDate(0, 0, -1).Format("20060102"),
|
||||
"dataAmountList": items,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Service) queryDataUsageDailyBatch(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
cards, key, err := findBatchCards(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
items = append(items, batchUsageItem(card, key, card.DailyBaseKB))
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"dataAmountList": items}))
|
||||
}
|
||||
|
||||
func (s *Service) queryAPNInfo(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
apns := make([]map[string]string, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
apns = append(apns, map[string]string{"apnName": apn.Name, "status": apn.Status})
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
|
||||
}
|
||||
|
||||
func (s *Service) queryCardBindStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
if r.URL.Query().Get("msisdn") == "" || r.URL.Query().Get("iccid") != "" || r.URL.Query().Get("imsi") != "" {
|
||||
writeOneLink(w, paramErr("card-bind-status requires msisdn only"))
|
||||
return
|
||||
}
|
||||
testType := r.URL.Query().Get("testType")
|
||||
if testType != "0" && testType != "1" {
|
||||
writeOneLink(w, paramErr("testType must be 0 or 1"))
|
||||
return
|
||||
}
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
result := card.CardBindStatus
|
||||
if result != "0" && result != "1" && result != "2" {
|
||||
result = "1"
|
||||
}
|
||||
data := map[string]string{"result": result}
|
||||
if result == "2" {
|
||||
data["errorCode"] = "MOCK_BIND_CHECK_FAILED"
|
||||
data["errorDes"] = "mock bind check failed"
|
||||
}
|
||||
writeOneLink(w, okResp(data))
|
||||
}
|
||||
|
||||
func (s *Service) queryOnOffStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"status": card.OnOffStatus}))
|
||||
}
|
||||
|
||||
func (s *Service) queryRegionLimitStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
card, err := findSingleCard(r, st)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(map[string]string{"regionLimitStatus": card.RegionLimitStatus}))
|
||||
}
|
||||
|
||||
func (s *Service) changeSIMStatus(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
q := r.URL.Query()
|
||||
idKey, idVal, err := singleIdentifier(q)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
operType := q.Get("operType")
|
||||
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
|
||||
var result map[string]string
|
||||
err = s.store.SaveMutation(func(store *Store) error {
|
||||
idx := findCardIndex(*store, idKey, idVal)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("查询不到信息")
|
||||
}
|
||||
card := &store.Cards[idx]
|
||||
card.CardStatus = statusByOperType(operType, card.CardStatus)
|
||||
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
|
||||
store.Orders = append(store.Orders, Order{
|
||||
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
|
||||
Type: "sim-status", OperType: operType, Status: "completed", CreatedAt: card.LastChangeDate,
|
||||
})
|
||||
result = map[string]string{idKey: idVal, "orderNum": orderNum}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(result))
|
||||
}
|
||||
|
||||
func (s *Service) operateSIMAPNFunction(w http.ResponseWriter, r *http.Request, st Store) {
|
||||
q := r.URL.Query()
|
||||
idKey, idVal, err := singleIdentifier(q)
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
apnName := q.Get("apnName")
|
||||
operType := q.Get("operType")
|
||||
if apnName == "" || (operType != "0" && operType != "1") {
|
||||
writeOneLink(w, paramErr("apnName 与 operType 必填"))
|
||||
return
|
||||
}
|
||||
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
|
||||
var result map[string]string
|
||||
err = s.store.SaveMutation(func(store *Store) error {
|
||||
idx := findCardIndex(*store, idKey, idVal)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("查询不到信息")
|
||||
}
|
||||
card := &store.Cards[idx]
|
||||
status := "0"
|
||||
if operType == "0" {
|
||||
status = "1"
|
||||
}
|
||||
found := false
|
||||
for i := range card.APNs {
|
||||
if strings.EqualFold(card.APNs[i].Name, apnName) {
|
||||
card.APNs[i].Status = status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
card.APNs = append(card.APNs, APN{Name: apnName, Status: status})
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
store.Orders = append(store.Orders, Order{
|
||||
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
|
||||
Type: "sim-apn-function", OperType: operType, APNName: apnName, Status: "completed", CreatedAt: now,
|
||||
})
|
||||
result = map[string]string{idKey: idVal, "orderNum": orderNum}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOneLink(w, paramErr(err.Error()))
|
||||
return
|
||||
}
|
||||
writeOneLink(w, okResp(result))
|
||||
}
|
||||
|
||||
func okResp(item interface{}) Response {
|
||||
return Response{Status: "0", Message: "正确", Result: []interface{}{item}}
|
||||
}
|
||||
|
||||
func paramErr(message string) Response {
|
||||
if strings.Contains(message, "查询不到") {
|
||||
return Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}}
|
||||
}
|
||||
return Response{Status: "12001", Message: message, Result: []interface{}{}}
|
||||
}
|
||||
|
||||
func writeOneLink(w http.ResponseWriter, resp Response) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func singleIdentifier(q mapLike) (string, string, error) {
|
||||
found := [][2]string{}
|
||||
for _, key := range []string{"msisdn", "iccid", "imsi"} {
|
||||
if v := q.Get(key); v != "" {
|
||||
found = append(found, [2]string{key, v})
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return "", "", fmt.Errorf("msisdn、iccid、imsi 必须有且只有一项")
|
||||
}
|
||||
return found[0][0], found[0][1], nil
|
||||
}
|
||||
|
||||
type mapLike interface {
|
||||
Get(string) string
|
||||
}
|
||||
|
||||
func findSingleCard(r *http.Request, st Store) (SIMCard, error) {
|
||||
key, value, err := singleIdentifier(r.URL.Query())
|
||||
if err != nil {
|
||||
return SIMCard{}, err
|
||||
}
|
||||
for _, card := range st.Cards {
|
||||
if matches(card, key, value) {
|
||||
return card, nil
|
||||
}
|
||||
}
|
||||
return SIMCard{}, fmt.Errorf("查询不到信息")
|
||||
}
|
||||
|
||||
func findBatchCards(r *http.Request, st Store) ([]SIMCard, string, error) {
|
||||
q := r.URL.Query()
|
||||
keys := []string{"msisdns", "iccids", "imsis"}
|
||||
foundKey := ""
|
||||
foundValue := ""
|
||||
for _, key := range keys {
|
||||
if v := q.Get(key); v != "" {
|
||||
if foundKey != "" {
|
||||
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
|
||||
}
|
||||
foundKey = key
|
||||
foundValue = v
|
||||
}
|
||||
}
|
||||
if foundKey == "" {
|
||||
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
|
||||
}
|
||||
singular := strings.TrimSuffix(foundKey, "s")
|
||||
ids := strings.Split(foundValue, "_")
|
||||
var cards []SIMCard
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
for _, card := range st.Cards {
|
||||
if matches(card, singular, id) {
|
||||
cards = append(cards, card)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return nil, "", fmt.Errorf("查询不到信息")
|
||||
}
|
||||
return cards, singular, nil
|
||||
}
|
||||
|
||||
func findCardIndex(st Store, key, value string) int {
|
||||
for i, card := range st.Cards {
|
||||
if matches(card, key, value) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func matches(card SIMCard, key, value string) bool {
|
||||
switch key {
|
||||
case "msisdn":
|
||||
return card.MSISDN == value
|
||||
case "iccid":
|
||||
return card.ICCID == value
|
||||
case "imsi":
|
||||
return card.IMSI == value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func apnUseAmountList(card SIMCard) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(card.APNs))
|
||||
for _, apn := range card.APNs {
|
||||
out = append(out, map[string]interface{}{
|
||||
"apnName": apn.Name,
|
||||
"apnUseAmount": fmtAmount(apn.UsageBytes / 1024),
|
||||
"pccCodeUseAmountList": []map[string]string{{
|
||||
"pccCode": "default",
|
||||
"pccCodeUseAmount": fmtAmount(apn.UsageBytes / 1024),
|
||||
}},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func batchUsageItem(card SIMCard, key string, amount float64) map[string]interface{} {
|
||||
item := map[string]interface{}{
|
||||
key: identifierValue(card, key),
|
||||
"dataAmount": fmtAmount(amount),
|
||||
}
|
||||
if amount > 0 {
|
||||
item["apnDataAmountList"] = []map[string]string{
|
||||
{"apnName": "CMIOT", "apnDataAmount": fmtAmount(amount * 0.7)},
|
||||
{"apnName": "CMMTM", "apnDataAmount": fmtAmount(amount * 0.3)},
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func identifierValue(card SIMCard, key string) string {
|
||||
switch key {
|
||||
case "iccid":
|
||||
return card.ICCID
|
||||
case "imsi":
|
||||
return card.IMSI
|
||||
default:
|
||||
return card.MSISDN
|
||||
}
|
||||
}
|
||||
|
||||
func fmtAmount(v float64) string {
|
||||
if math.Abs(v-math.Round(v)) < 0.001 {
|
||||
return strconv.FormatInt(int64(math.Round(v)), 10)
|
||||
}
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
func statusByOperType(operType, current string) string {
|
||||
switch operType {
|
||||
case "0":
|
||||
return "4"
|
||||
case "1", "2", "5", "6":
|
||||
return "2"
|
||||
case "3", "8":
|
||||
return "7"
|
||||
case "4", "9":
|
||||
return "1"
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StoreManager struct {
|
||||
mu sync.Mutex
|
||||
backend storeBackend
|
||||
data Store
|
||||
initialized bool
|
||||
}
|
||||
|
||||
type storeBackend interface {
|
||||
Load() (Store, error)
|
||||
Save(Store) error
|
||||
}
|
||||
|
||||
type generatedCardBackend interface {
|
||||
AppendCards([]SIMCard) error
|
||||
}
|
||||
|
||||
func NewStoreManager(path string) (*StoreManager, error) {
|
||||
backend, err := newStoreBackend(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sm := &StoreManager{backend: backend}
|
||||
if err := sm.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
func (s *StoreManager) Snapshot() Store {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return cloneStore(s.data)
|
||||
}
|
||||
|
||||
func (s *StoreManager) SaveMutation(fn func(*Store) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := fn(&s.data); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *StoreManager) WithStore(fn func(*Store) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return fn(&s.data)
|
||||
}
|
||||
|
||||
func (s *StoreManager) Persist() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *StoreManager) GenerateCards(count int) (GenerateResult, error) {
|
||||
return s.GenerateCardsInGroup(count, 0)
|
||||
}
|
||||
|
||||
func (s *StoreManager) GenerateCardsInGroup(count int, groupID int64) (GenerateResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if count <= 0 {
|
||||
return GenerateResult{Total: len(s.data.Cards)}, nil
|
||||
}
|
||||
generator, ok := s.backend.(generatedCardBackend)
|
||||
if !ok {
|
||||
return GenerateResult{}, fmt.Errorf("current store backend does not support card generation")
|
||||
}
|
||||
if groupID != 0 {
|
||||
found := false
|
||||
for _, group := range s.data.Groups {
|
||||
if group.ID == groupID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return GenerateResult{}, fmt.Errorf("card group not found")
|
||||
}
|
||||
}
|
||||
cards := GenerateSIMCards(time.Now(), count)
|
||||
for i := range cards {
|
||||
cards[i].GroupID = groupID
|
||||
for _, group := range s.data.Groups {
|
||||
if group.ID == groupID {
|
||||
cards[i].GroupName = group.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := generator.AppendCards(cards); err != nil {
|
||||
return GenerateResult{}, err
|
||||
}
|
||||
s.data.Cards = append(s.data.Cards, cards...)
|
||||
return GenerateResult{Generated: len(cards), Total: len(s.data.Cards)}, nil
|
||||
}
|
||||
|
||||
func (s *StoreManager) QueryCards(keyword, exportStatus string, page, pageSize int) CardPage {
|
||||
return s.QueryCardsByGroup(keyword, exportStatus, 0, false, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *StoreManager) QueryCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool, page, pageSize int) CardPage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
matched := filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(matched) {
|
||||
start = len(matched)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(matched) {
|
||||
end = len(matched)
|
||||
}
|
||||
return CardPage{Items: append([]SIMCard{}, matched[start:end]...), Total: len(matched), Page: page, PageSize: pageSize}
|
||||
}
|
||||
|
||||
func (s *StoreManager) QueryAllCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]SIMCard{}, filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)...)
|
||||
}
|
||||
|
||||
func filterCards(cards []SIMCard, keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
matched := make([]SIMCard, 0)
|
||||
for i := len(cards) - 1; i >= 0; i-- {
|
||||
card := cards[i]
|
||||
groupMatch := !filterGroup || card.GroupID == groupID
|
||||
exportMatch := exportStatus == "" || (exportStatus == "exported" && card.Exported) || (exportStatus == "not_exported" && !card.Exported)
|
||||
if groupMatch && exportMatch && (keyword == "" || card.MSISDN == keyword || card.ICCID == keyword || card.IMSI == keyword) {
|
||||
matched = append(matched, card)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func (s *StoreManager) QueryOrders(page, pageSize int) OrderPage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
total := len(s.data.Orders)
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := make([]Order, 0, end-start)
|
||||
for i := total - 1 - start; i >= total-end; i-- {
|
||||
items = append(items, s.data.Orders[i])
|
||||
}
|
||||
return OrderPage{Items: items, Total: total, Page: page, PageSize: pageSize}
|
||||
}
|
||||
|
||||
func (s *StoreManager) load() error {
|
||||
return s.reloadLocked()
|
||||
}
|
||||
|
||||
func (s *StoreManager) reloadLocked() error {
|
||||
data, err := s.backend.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.initialized {
|
||||
data.AppIDs = s.data.AppIDs
|
||||
data.Tokens = s.data.Tokens
|
||||
data.Orders = s.data.Orders
|
||||
data.Settings = s.data.Settings
|
||||
}
|
||||
s.data = data
|
||||
if s.data.Tokens == nil {
|
||||
s.data.Tokens = map[string]Token{}
|
||||
}
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StoreManager) saveLocked() error {
|
||||
return s.backend.Save(s.data)
|
||||
}
|
||||
|
||||
func defaultStore() Store {
|
||||
now := time.Now()
|
||||
return Store{
|
||||
AppIDs: []Credential{
|
||||
{
|
||||
AppID: "demo_appid",
|
||||
Password: "demo_password",
|
||||
Name: "默认测试应用",
|
||||
Enabled: true,
|
||||
CreatedAt: now.Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
},
|
||||
Cards: []SIMCard{},
|
||||
Groups: []CardGroup{},
|
||||
Tokens: map[string]Token{},
|
||||
Orders: []Order{},
|
||||
Settings: SimulationSettings{
|
||||
AutoGrowthEnabled: false,
|
||||
DefaultGrowthKB: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleCard(msisdn, iccid, imsi, imei, remark string, monthKB float64) SIMCard {
|
||||
now := time.Now()
|
||||
return SIMCard{
|
||||
MSISDN: msisdn,
|
||||
ICCID: iccid,
|
||||
IMSI: imsi,
|
||||
IMEI: imei,
|
||||
Remark: remark,
|
||||
CardStatus: "2",
|
||||
LastChangeDate: now.AddDate(0, -1, 0).Format("2006-01-02 15:04:05"),
|
||||
OpenDate: now.AddDate(-1, 0, 0).Format("2006-01-02 15:04:05"),
|
||||
ActiveDate: now.AddDate(-1, 0, 3).Format("2006-01-02 15:04:05"),
|
||||
OnOffStatus: "1",
|
||||
SessionStatus: "01",
|
||||
IP: fmt.Sprintf("10.8.%d.%d", len(msisdn), now.Second()+20),
|
||||
IPv6Prefix: "",
|
||||
IPv6: "",
|
||||
RAT: "6",
|
||||
CardBindStatus: "1",
|
||||
RegionLimitStatus: "0",
|
||||
AreaRestrictType: "1",
|
||||
RestrictedArea: "200",
|
||||
MonthUsageKB: monthKB,
|
||||
PoolUsageBytes: monthKB * 1024,
|
||||
DailyBaseKB: monthKB / 30,
|
||||
MonthlyBaseKB: monthKB,
|
||||
GrowthPerMinuteKB: 1.5,
|
||||
LastUsageUpdateTime: now.Format(time.RFC3339),
|
||||
APNs: []APN{
|
||||
{Name: "CMIOT", Status: "1", UsageBytes: monthKB * 1024 * 0.7},
|
||||
{Name: "CMMTM", Status: "1", UsageBytes: monthKB * 1024 * 0.3},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStore(src Store) Store {
|
||||
b, _ := json.Marshal(src)
|
||||
var out Store
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package service
|
||||
|
||||
func newStoreBackend(path string) (storeBackend, error) {
|
||||
return newSQLiteStoreBackend(path)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type dbAPN struct {
|
||||
APNName string `json:"apnName"`
|
||||
Status string `json:"status"`
|
||||
DiagnosisStatus string `json:"diagnosisStatus"`
|
||||
UsageBytes json.Number `json:"usageBytes"`
|
||||
ModifyTime string `json:"modifyTime"`
|
||||
OverUseDetails string `json:"overUseDetails"`
|
||||
ServiceUsageState string `json:"serviceUsageState"`
|
||||
PCCs []dbPCC `json:"pccs"`
|
||||
}
|
||||
|
||||
type dbPCC struct {
|
||||
ServiceCode string `json:"serviceCode"`
|
||||
Name string `json:"name"`
|
||||
PolicyStatus string `json:"policyStatus"`
|
||||
ServiceUsageState string `json:"serviceUsageState"`
|
||||
ModifyTime string `json:"modifyTime"`
|
||||
}
|
||||
|
||||
func parseAPNs(raw, fallbackName, fallbackStatus string, fallbackUsageKB float64, fallbackUsageBytes int64) []APN {
|
||||
var rows []dbAPN
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if raw != "" && decoder.Decode(&rows) == nil {
|
||||
apns := make([]APN, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
usageBytes, _ := strconv.ParseFloat(row.UsageBytes.String(), 64)
|
||||
pccs := make([]PCC, 0, len(row.PCCs))
|
||||
for _, pcc := range row.PCCs {
|
||||
pccs = append(pccs, PCC{ServiceCode: pcc.ServiceCode, Name: pcc.Name, PolicyStatus: pcc.PolicyStatus, ServiceUsageState: pcc.ServiceUsageState, ModifyTime: pcc.ModifyTime})
|
||||
}
|
||||
apns = append(apns, APN{Name: row.APNName, Status: row.Status, DiagnosisStatus: row.DiagnosisStatus, UsageBytes: usageBytes, ModifyTime: row.ModifyTime, OverUseDetails: row.OverUseDetails, ServiceUsageState: row.ServiceUsageState, PCCs: pccs})
|
||||
}
|
||||
return apns
|
||||
}
|
||||
usageBytes := float64(fallbackUsageBytes)
|
||||
if usageBytes == 0 && fallbackUsageKB > 0 {
|
||||
usageBytes = fallbackUsageKB * 1024
|
||||
}
|
||||
if fallbackName == "" {
|
||||
return nil
|
||||
}
|
||||
return []APN{{Name: fallbackName, Status: fallbackStatus, UsageBytes: usageBytes}}
|
||||
}
|
||||
|
||||
func apnsForDB(apns []APN) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(apns))
|
||||
for _, apn := range apns {
|
||||
out = append(out, map[string]interface{}{
|
||||
"apnName": apn.Name,
|
||||
"status": apn.Status,
|
||||
"diagnosisStatus": apn.DiagnosisStatus,
|
||||
"usageBytes": int64(apn.UsageBytes),
|
||||
"modifyTime": apn.ModifyTime,
|
||||
"overUseDetails": apn.OverUseDetails,
|
||||
"serviceUsageState": apn.ServiceUsageState,
|
||||
"pccs": pccsForDB(apn.PCCs),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pccsForDB(pccs []PCC) []map[string]string {
|
||||
out := make([]map[string]string, 0, len(pccs))
|
||||
for _, pcc := range pccs {
|
||||
out = append(out, map[string]string{
|
||||
"serviceCode": pcc.ServiceCode, "name": pcc.Name, "policyStatus": pcc.PolicyStatus,
|
||||
"serviceUsageState": pcc.ServiceUsageState, "modifyTime": pcc.ModifyTime,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func primaryAPN(card SIMCard) APN {
|
||||
for _, apn := range card.APNs {
|
||||
if apn.Status == "1" {
|
||||
return apn
|
||||
}
|
||||
}
|
||||
if len(card.APNs) > 0 {
|
||||
return card.APNs[0]
|
||||
}
|
||||
return APN{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}
|
||||
}
|
||||
|
||||
func sourceRemark(carrier, packageCode, packageName string) string {
|
||||
return fmt.Sprintf("carrier=%s; package=%s; name=%s; channel=%s; supplier=%s", carrier, packageCode, packageName, generatedChanKey, generatedSupplierCode)
|
||||
}
|
||||
|
||||
func mockMetadataFromRemark(remark string) (string, string, string) {
|
||||
values := map[string]string{}
|
||||
for _, part := range strings.Split(remark, ";") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if ok {
|
||||
values[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
carrier := values["carrier"]
|
||||
if carrier == "" {
|
||||
carrier = "CMCC"
|
||||
}
|
||||
packageCode := values["package"]
|
||||
if packageCode == "" {
|
||||
packageCode = generatedPackageCode
|
||||
}
|
||||
packageName := values["name"]
|
||||
if packageName == "" {
|
||||
packageName = generatedPackageCode
|
||||
}
|
||||
return carrier, packageCode, packageName
|
||||
}
|
||||
|
||||
func dbTimeToRFC3339(value string) string {
|
||||
if value == "" {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil {
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func rfc3339ToDBTime(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
|
||||
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/manifest"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
var sqliteStoreSequence atomic.Uint64
|
||||
|
||||
type sqliteStoreBackend struct {
|
||||
db gdb.DB
|
||||
ctx context.Context
|
||||
keepOpen bool
|
||||
}
|
||||
|
||||
func newSQLiteStoreBackend(databaseLink string) (storeBackend, error) {
|
||||
if !strings.HasPrefix(databaseLink, "sqlite:") {
|
||||
if strings.TrimSpace(databaseLink) == "" {
|
||||
databaseLink = filepath.Join("data", "onelink_mock.db")
|
||||
}
|
||||
if databaseLink != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(databaseLink), 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "create sqlite directory")
|
||||
}
|
||||
}
|
||||
databaseLink = fmt.Sprintf("sqlite::@file(%s)", filepath.ToSlash(databaseLink))
|
||||
}
|
||||
group := fmt.Sprintf("onelink_sqlite_%d", sqliteStoreSequence.Add(1))
|
||||
if err := gdb.AddConfigNode(group, gdb.ConfigNode{
|
||||
Type: "sqlite", Link: databaseLink,
|
||||
MaxIdleConnCount: 1, MaxOpenConnCount: 1, MaxConnLifeTime: 24 * time.Hour,
|
||||
}); err != nil {
|
||||
return nil, gerror.Wrap(err, "configure sqlite")
|
||||
}
|
||||
db, err := gdb.NewByGroup(group)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "open sqlite")
|
||||
}
|
||||
backend := &sqliteStoreBackend{db: db, ctx: context.Background(), keepOpen: strings.Contains(databaseLink, ":memory:")}
|
||||
if err = backend.ensureSchema(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !backend.keepOpen {
|
||||
if err = db.Close(backend.ctx); err != nil {
|
||||
return nil, gerror.Wrap(err, "close sqlite after initialization")
|
||||
}
|
||||
}
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func (b *sqliteStoreBackend) close() error {
|
||||
if b.keepOpen {
|
||||
return nil
|
||||
}
|
||||
if err := b.db.Close(b.ctx); err != nil {
|
||||
return gerror.Wrap(err, "close sqlite connection")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *sqliteStoreBackend) ensureSchema() error {
|
||||
if _, err := b.db.Exec(b.ctx, manifest.SchemaSQL); err != nil {
|
||||
return gerror.Wrap(err, "initialize sqlite schema")
|
||||
}
|
||||
rows, err := b.db.GetAll(b.ctx, "PRAGMA table_info(mock_t_sim_card)")
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "inspect sqlite schema")
|
||||
}
|
||||
columns := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
columns[row["name"].String()] = struct{}{}
|
||||
}
|
||||
migrations := map[string]string{
|
||||
"group_id": "ALTER TABLE mock_t_sim_card ADD COLUMN group_id INTEGER",
|
||||
"stop_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN stop_reason TEXT",
|
||||
"frozen_status": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_status TEXT",
|
||||
"frozen_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_reason TEXT",
|
||||
"frozen_oper_time": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_oper_time TEXT",
|
||||
"exported": "ALTER TABLE mock_t_sim_card ADD COLUMN exported INTEGER NOT NULL DEFAULT 0",
|
||||
"export_checked_at": "ALTER TABLE mock_t_sim_card ADD COLUMN export_checked_at TEXT",
|
||||
}
|
||||
for column, statement := range migrations {
|
||||
if _, ok := columns[column]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err = b.db.Exec(b.ctx, statement); err != nil {
|
||||
return gerror.Wrapf(err, "add sqlite column %s", column)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *sqliteStoreBackend) Load() (Store, error) {
|
||||
defer b.close()
|
||||
data := defaultStore()
|
||||
var groups []entity.MockCardGroup
|
||||
if err := b.db.Model(dao.MockCardGroup.Table()).Ctx(b.ctx).OrderAsc(dao.MockCardGroup.Columns().Id).Scan(&groups); err != nil {
|
||||
return Store{}, gerror.Wrap(err, "load card groups")
|
||||
}
|
||||
data.Groups = make([]CardGroup, 0, len(groups))
|
||||
groupNames := make(map[int64]string, len(groups))
|
||||
for _, row := range groups {
|
||||
group := CardGroup{ID: int64(row.Id), Name: row.Name, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
data.Groups = append(data.Groups, group)
|
||||
groupNames[group.ID] = group.Name
|
||||
}
|
||||
var rows []entity.MockTSimCard
|
||||
if err := b.db.Model(dao.MockTSimCard.Table()).Ctx(b.ctx).OrderAsc(dao.MockTSimCard.Columns().Id).Scan(&rows); err != nil {
|
||||
return Store{}, gerror.Wrap(err, "load sim cards")
|
||||
}
|
||||
data.Cards = make([]SIMCard, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
card := SIMCard{
|
||||
GroupID: int64(row.GroupId), GroupName: groupNames[int64(row.GroupId)],
|
||||
MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, IMEI: row.Imei, Remark: row.Remark,
|
||||
CardStatus: row.CardStatus, LastChangeDate: row.LastChangeDate, StopReason: row.StopReason,
|
||||
FrozenStatus: row.FrozenStatus, FrozenReason: row.FrozenReason, FrozenOperTime: row.FrozenOperTime,
|
||||
OpenDate: row.OpenDate, ActiveDate: row.ActiveDate, OnOffStatus: row.OnOffStatus,
|
||||
SessionStatus: row.SessionStatus, SessionCreateDate: row.SessionCreateDate,
|
||||
IP: row.SessionIpv4, IPv6Prefix: row.SessionIpv6Prefix, IPv6: row.SessionIpv6, RAT: row.Rat,
|
||||
CardBindStatus: row.CardBindStatus, RegionLimitStatus: row.RegionLimitStatus,
|
||||
AreaRestrictType: row.AreaRestrictType, RestrictedArea: row.RestrictedArea,
|
||||
MonthUsageKB: float64(row.CurrentMonthUsageKb), PoolUsageBytes: float64(row.PoolUsageBytes),
|
||||
DailyBaseKB: float64(row.DailyUsageKb), MonthlyBaseKB: float64(row.MonthlyUsageKb),
|
||||
GrowthPerMinuteKB: float64(row.GrowthPerMinuteKb), LastUsageUpdateTime: dbTimeToRFC3339(row.LastUsageUpdateTime),
|
||||
Exported: row.Exported != 0, ExportCheckedAt: row.ExportCheckedAt,
|
||||
}
|
||||
card.APNs = parseAPNs(row.ApnListJson, row.PrimaryApnName, row.PrimaryApnStatus, float64(row.PrimaryApnUsageKb), int64(row.PrimaryApnUsageBytes))
|
||||
data.Cards = append(data.Cards, card)
|
||||
}
|
||||
var orders []entity.MockOrder
|
||||
if err := b.db.Model(dao.MockOrder.Table()).Ctx(b.ctx).OrderAsc(dao.MockOrder.Columns().Id).Scan(&orders); err != nil {
|
||||
return Store{}, gerror.Wrap(err, "load orders")
|
||||
}
|
||||
data.Orders = make([]Order, 0, len(orders))
|
||||
for _, row := range orders {
|
||||
data.Orders = append(data.Orders, Order{OrderNum: row.OrderNum, MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, Type: row.Type, OperType: row.OperType, APNName: row.ApnName, Status: row.Status, CreatedAt: row.CreatedAt})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (b *sqliteStoreBackend) Save(data Store) error {
|
||||
defer b.close()
|
||||
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
|
||||
return gerror.Wrap(err, "initialize sqlite schema")
|
||||
}
|
||||
for _, table := range []string{dao.MockOrder.Table(), dao.MockTSimCard.Table(), dao.MockCardGroup.Table()} {
|
||||
if _, err := tx.Model(table).Ctx(ctx).Where("1=1").Delete(); err != nil {
|
||||
return gerror.Wrap(err, "clear sqlite table")
|
||||
}
|
||||
}
|
||||
if err := insertGroups(ctx, tx, data.Groups); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertCards(ctx, tx, data.Cards); err != nil {
|
||||
return err
|
||||
}
|
||||
return insertOrders(ctx, tx, data.Orders)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *sqliteStoreBackend) AppendCards(cards []SIMCard) error {
|
||||
defer b.close()
|
||||
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
|
||||
return gerror.Wrap(err, "initialize sqlite schema")
|
||||
}
|
||||
return insertCards(ctx, tx, cards)
|
||||
})
|
||||
}
|
||||
|
||||
func insertGroups(ctx context.Context, tx gdb.TX, groups []CardGroup) error {
|
||||
for _, item := range groups {
|
||||
if _, err := tx.Model(dao.MockCardGroup.Table()).Ctx(ctx).Data(do.MockCardGroup{Id: item.ID, Name: item.Name, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert card group")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertCards(ctx context.Context, tx gdb.TX, cards []SIMCard) error {
|
||||
for _, item := range cards {
|
||||
normalizeCard(&item)
|
||||
primary := primaryAPN(item)
|
||||
apnList, err := json.Marshal(apnsForDB(item.APNs))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "encode apn list")
|
||||
}
|
||||
carrier, packageCode, packageName := mockMetadataFromRemark(item.Remark)
|
||||
data := do.MockTSimCard{
|
||||
GroupId: item.GroupID, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Imei: item.IMEI,
|
||||
Remark: item.Remark, Carrier: carrier, PackageCode: packageCode, PackageName: packageName,
|
||||
PackageStatus: 1, PackageTotalFlowMb: item.MonthlyBaseKB / 1024, IsFlowPool: 1,
|
||||
CardStatus: item.CardStatus, LastChangeDate: item.LastChangeDate, StopReason: item.StopReason,
|
||||
FrozenStatus: item.FrozenStatus, FrozenReason: item.FrozenReason, FrozenOperTime: item.FrozenOperTime,
|
||||
OpenDate: item.OpenDate, ActiveDate: item.ActiveDate, OnOffStatus: item.OnOffStatus, SessionStatus: item.SessionStatus,
|
||||
ApnId: primary.Name, SessionIpv4: item.IP, SessionIpv6Prefix: item.IPv6Prefix, SessionIpv6: item.IPv6,
|
||||
SessionCreateDate: item.SessionCreateDate, Rat: item.RAT, ApnListJson: string(apnList), ApnUsageJson: string(apnList),
|
||||
PrimaryApnName: primary.Name, PrimaryApnStatus: primary.Status, PrimaryApnUsageKb: primary.UsageBytes / 1024,
|
||||
PrimaryApnUsageBytes: int64(primary.UsageBytes), PccCode: "default", PccUsageKb: primary.UsageBytes / 1024,
|
||||
CurrentMonthUsageKb: item.MonthUsageKB, PoolUsageBytes: int64(item.PoolUsageBytes), DailyUsageKb: item.DailyBaseKB,
|
||||
MonthlyUsageKb: item.MonthlyBaseKB, DailyQueryDate: parseCardTime(item.ActiveDate).Format("20060102"),
|
||||
MonthlyQueryMonth: parseCardTime(item.ActiveDate).Format("200601"), MonthlyDeadline: parseCardTime(item.ActiveDate).AddDate(0, 0, -1).Format("20060102"),
|
||||
GrowthPerMinuteKb: item.GrowthPerMinuteKB, AutoGrowthEnabled: 0, LastUsageUpdateTime: rfc3339ToDBTime(item.LastUsageUpdateTime),
|
||||
CardBindStatus: item.CardBindStatus, RegionLimitStatus: item.RegionLimitStatus, AreaRestrictType: item.AreaRestrictType,
|
||||
RestrictedArea: item.RestrictedArea, Exported: boolInt(item.Exported), ExportCheckedAt: item.ExportCheckedAt,
|
||||
CreatedAt: item.OpenDate, UpdatedAt: item.LastChangeDate,
|
||||
}
|
||||
if _, err = tx.Model(dao.MockTSimCard.Table()).Ctx(ctx).Data(data).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert sim card")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertOrders(ctx context.Context, tx gdb.TX, orders []Order) error {
|
||||
for _, item := range orders {
|
||||
if _, err := tx.Model(dao.MockOrder.Table()).Ctx(ctx).Data(do.MockOrder{OrderNum: item.OrderNum, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Type: item.Type, OperType: item.OperType, ApnName: item.APNName, Status: item.Status, CreatedAt: item.CreatedAt}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert order")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStoreManagerUsesSQLiteWithoutExternalDatabase(t *testing.T) {
|
||||
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := manager.Snapshot()
|
||||
if len(got.Cards) != 0 {
|
||||
t.Fatalf("new sqlite store should not contain sample cards, got %#v", got.Cards)
|
||||
}
|
||||
if len(got.AppIDs) == 0 || got.AppIDs[0].AppID != "demo_appid" {
|
||||
t.Fatalf("default app id not preserved: %#v", got.AppIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreManagerGenerateCardsAppendsWithoutDeletingOldCards(t *testing.T) {
|
||||
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := manager.GenerateCards(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Generated != 2 {
|
||||
t.Fatalf("first generated = %d, want 2", first.Generated)
|
||||
}
|
||||
second, err := manager.GenerateCards(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Generated != 1 || second.Total != 3 {
|
||||
t.Fatalf("second result = %#v, want generated 1 total 3", second)
|
||||
}
|
||||
got := manager.Snapshot()
|
||||
if len(got.Cards) != 3 {
|
||||
t.Fatalf("generated cards should append, got %d cards: %#v", len(got.Cards), got.Cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersPersistAcrossStoreRestart(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/onelink_mock.db"
|
||||
manager, err := NewStoreManager(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Order{
|
||||
OrderNum: "ORD202607270001", MSISDN: "14100000001", ICCID: "8986000000000000001",
|
||||
IMSI: "460000000000001", Type: "sim-apn-function", OperType: "1",
|
||||
APNName: "CMIOT", Status: "completed", CreatedAt: "2026-07-27 16:00:00",
|
||||
}
|
||||
if err := manager.SaveMutation(func(st *Store) error {
|
||||
st.Orders = append(st.Orders, want)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := NewStoreManager(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := reopened.Snapshot().Orders
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("orders after restart = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user