package sub import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "ops-assistant/internal/core/command" "ops-assistant/internal/core/ecode" "ops-assistant/models" "gorm.io/gorm" ) type Module struct { db *gorm.DB } type settings struct { APIBase string Token string } type subItem struct { ID string `json:"id"` Name string `json:"name"` Category string `json:"category"` Account string `json:"account"` Amount float64 `json:"amount"` Currency string `json:"currency"` DueDate string `json:"due_date"` Cycle string `json:"cycle"` CycleUnit string `json:"cycle_unit"` CycleValue int `json:"cycle_value"` Status string `json:"status"` UserID string `json:"user_id"` Channel string `json:"channel"` Remind []int `json:"remind_days"` } type listResp struct { Success bool `json:"success"` Data []subItem `json:"data"` Error string `json:"error"` } type simpleResp struct { Success bool `json:"success"` Error string `json:"error"` } func New(db *gorm.DB) *Module { return &Module{db: db} } func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { text := strings.TrimSpace(cmd.Raw) if text == "/sub" || strings.HasPrefix(text, "/sub help") { return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del \n- /sub cancel(或 /cancel)取消新增流程", nil } parts := strings.Fields(text) if len(parts) < 2 { return "❌ 用法:/sub add | /sub list | /sub del ", nil } switch parts[1] { case "add": return "🧩 进入订阅新增流程:名称→分类→账号(可选)→金额→币种→到期日→周期单位→周期数量", nil case "list": return m.list(userID) case "report": return m.report(userID, parts[2:]) case "del", "delete": if len(parts) < 3 { return "❌ 用法:/sub del ", nil } return m.del(userID, strings.TrimSpace(parts[2])) default: return "❌ 用法:/sub add | /sub list | /sub del ", nil } } func formatCycleText(unit string, value int, legacy string) string { u := strings.TrimSpace(unit) if u == "" { if strings.TrimSpace(legacy) != "" { return legacy } return "-" } if value <= 0 { value = 1 } return fmt.Sprintf("%s x%d", u, value) } func (m *Module) list(userID int64) (string, error) { cfg, err := m.loadSettings() if err != nil { return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil } url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions?user_id=1&channel=qq" body, status, err := m.doRequest(http.MethodGet, url, nil, cfg) if err != nil { return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil } if status >= 400 { return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil } var out listResp if err := json.Unmarshal([]byte(body), &out); err != nil { return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil } if !out.Success { return ecode.Tag(ecode.ErrStepFailed, out.Error), nil } if len(out.Data) == 0 { return "📭 订阅列表为空", nil } lines := make([]string, 0, len(out.Data)+2) lines = append(lines, fmt.Sprintf("✅ 订阅列表(%d)", len(out.Data))) limit := len(out.Data) if limit > 50 { limit = 50 } for i := 0; i < limit; i++ { it := out.Data[i] cycleText := formatCycleText(it.CycleUnit, it.CycleValue, it.Cycle) line := fmt.Sprintf("- ID=%s | %s | 分类:%s | 到期:%s | 周期:%s | 金额:%.2f %s", it.ID, it.Name, it.Category, it.DueDate, cycleText, it.Amount, it.Currency) if strings.TrimSpace(it.Account) != "" { line += fmt.Sprintf(" | 账号:%s", it.Account) } lines = append(lines, line) } if len(out.Data) > limit { lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit)) } return strings.Join(lines, "\n"), nil } func (m *Module) del(userID int64, id string) (string, error) { if strings.TrimSpace(id) == "" { return "❌ id 不能为空", nil } cfg, err := m.loadSettings() if err != nil { return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil } url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions/" + id + "?user_id=1&channel=qq" body, status, err := m.doRequest(http.MethodDelete, url, nil, cfg) if err != nil { return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil } if status >= 400 { return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil } var out simpleResp if err := json.Unmarshal([]byte(body), &out); err != nil { return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil } if !out.Success { return ecode.Tag(ecode.ErrStepFailed, out.Error), nil } return fmt.Sprintf("✅ 订阅已删除:%s", id), nil } func (m *Module) loadSettings() (*settings, error) { if m.db == nil { return nil, errors.New("db 未初始化") } keys := []string{"sub_api_base", "sub_api_token"} cfg := &settings{} for _, k := range keys { var s models.AppSetting val := "" if err := m.db.Where("key = ?", k).First(&s).Error; err == nil { val = strings.TrimSpace(s.Value) } switch k { case "sub_api_base": cfg.APIBase = strings.TrimSpace(val) case "sub_api_token": cfg.Token = val } } if cfg.APIBase == "" { return nil, errors.New("sub_api_base 未配置") } if cfg.Token == "" { return nil, errors.New("sub_api_token 未配置") } return cfg, nil } func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) { req, err := http.NewRequest(method, url, strings.NewReader(string(body))) if err != nil { return "", 0, err } if cfg.Token != "" { req.Header.Set("Authorization", "Bearer "+cfg.Token) } if body != nil { req.Header.Set("Content-Type", "application/json") } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) return string(data), resp.StatusCode, nil }