diff --git a/.codebuddy/docs/admin-miniapp.md b/.codebuddy/docs/admin-miniapp.md index 32df8b5..a6802cd 100644 --- a/.codebuddy/docs/admin-miniapp.md +++ b/.codebuddy/docs/admin-miniapp.md @@ -114,6 +114,39 @@ curl "http://localhost:8080/api/admin/miniapps?status=1&page=1&size=20" \ { "code": 0, "msg": "ok" } ``` +### 1.6 `PUT /api/admin/miniapps/:id/status` + +更新小程序状态(启用 / 停用)。 + +**路径参数** + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `id` | uint64 | 小程序主键。 | + +**请求体** + +```json +{ "status": 1 } +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `status` | int8 | 是 | 状态:`0` 停用,`1` 启用。仅允许这两个值,非法值返回 `40000`。 | + +> 该接口仅更新 `status` 字段,不影响其它字段;常用于停用 / 启用小程序。 + +**响应**:返回更新后的 `MiniProgram` 对象(见 §2.1),`app_secret` 字段不返回。 + +**curl 示例** + +```bash +curl -X PUT "http://localhost:8080/api/admin/miniapps/1/status" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"status":0}' +``` + ## 2. 数据模型 ### 2.1 MiniProgram(被纳管小程序) @@ -127,8 +160,8 @@ curl "http://localhost:8080/api/admin/miniapps?status=1&page=1&size=20" \ | `AppSecret` | string | `app_secret` | **不返回**(json `-`)。 | | `Status` | int8 | `status` | `0` 停用,`1` 启用。 | | `Ext` | string | `ext` | 扩展 JSON 文本。 | -| `CreatedAt` | time | `created_at` | 创建时间。 | -| `UpdatedAt` | time | `updated_at` | 更新时间。 | +| `CreatedAt` | time | `created_at` | 创建时间,格式 `Y-m-d H:i:s`(如 `2026-10-26 15:04:05`)。 | +| `UpdatedAt` | time | `updated_at` | 更新时间,格式 `Y-m-d H:i:s`。 | ## 3. 备注 diff --git a/internal/handler/admin/miniapp/miniapp_handler.go b/internal/handler/admin/miniapp/miniapp_handler.go index f1e3e61..cffe2fe 100644 --- a/internal/handler/admin/miniapp/miniapp_handler.go +++ b/internal/handler/admin/miniapp/miniapp_handler.go @@ -97,6 +97,25 @@ func (h *Handler) Delete(c *gin.Context) { response.OK(c, nil) } +// UpdateStatus 更新小程序状态(0 禁用、1 启用)。 +func (h *Handler) UpdateStatus(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req miniappsvc.StatusReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + m, err := h.svc.UpdateStatus(c.Request.Context(), id, req.Status) + if err != nil { + c.Error(err) + return + } + response.OK(c, m) +} + // parseID 从路径参数解析并校验 uint64 主键。 func parseID(c *gin.Context) (uint64, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -117,5 +136,6 @@ func Register(g *gin.RouterGroup, h *Handler) { rg.GET("/:id", middleware.RequirePerm(middleware.PermMiniView), h.Get) rg.GET("", middleware.RequirePerm(middleware.PermMiniView), h.List) rg.DELETE("/:id", middleware.RequirePerm(middleware.PermMiniManage), h.Delete) + rg.PUT("/:id/status", middleware.RequirePerm(middleware.PermMiniManage), h.UpdateStatus) } } diff --git a/internal/handler/miniapp/app1/user_handler.go b/internal/handler/miniapp/app1/user_handler.go index c95c6f2..f760238 100644 --- a/internal/handler/miniapp/app1/user_handler.go +++ b/internal/handler/miniapp/app1/user_handler.go @@ -10,6 +10,7 @@ import ( "portal-mini-go/internal/model" "portal-mini-go/internal/pkg/errors" "portal-mini-go/internal/pkg/response" + "portal-mini-go/internal/pkg/types" app1svc "portal-mini-go/internal/service/miniapp/app1" ) @@ -110,12 +111,12 @@ func (h *Handler) ListUsers(c *gin.Context) { // ProfileView 当前用户自身的资料视图(含自身 openid/unionid)。 type ProfileView struct { - ID uint64 `json:"id"` - OpenID string `json:"openid"` - UnionID string `json:"union_id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - CreatedAt int64 `json:"created_at"` + ID uint64 `json:"id"` + OpenID string `json:"openid"` + UnionID string `json:"union_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + CreatedAt types.Time `json:"created_at"` } // PublicUserView 对外的用户公开视图,剔除 openid/unionid 等敏感字段。 @@ -132,7 +133,7 @@ func toProfileView(u *model.User) ProfileView { UnionID: u.UnionID, Nickname: u.Nickname, Avatar: u.Avatar, - CreatedAt: u.CreatedAt.Unix(), + CreatedAt: u.CreatedAt, } } diff --git a/internal/model/admin/admin.go b/internal/model/admin/admin.go index eaf0a81..27cf2f3 100644 --- a/internal/model/admin/admin.go +++ b/internal/model/admin/admin.go @@ -3,17 +3,17 @@ // 因此本包模型不进小程序数据隔离逻辑。 package admin -import "time" +import "portal-mini-go/internal/pkg/types" // User 是后台管理员账号。用户名全局唯一,密码以 bcrypt 哈希存储。 type User struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` - Nickname string `gorm:"column:nickname;type:varchar(128)" json:"nickname"` - Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + Nickname string `gorm:"column:nickname;type:varchar(128)" json:"nickname"` + Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` + CreatedAt types.Time `json:"created_at"` + UpdatedAt types.Time `json:"updated_at"` } func (User) TableName() string { return "admin_users" } @@ -26,13 +26,13 @@ const ( // Role 是后台角色,与权限多对多关联,再与管理员多对多关联。 type Role struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - Code string `gorm:"column:code;type:varchar(64);uniqueIndex;not null" json:"code"` - Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` - Description string `gorm:"column:description;type:varchar(512)" json:"description"` - Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"column:code;type:varchar(64);uniqueIndex;not null" json:"code"` + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` + Description string `gorm:"column:description;type:varchar(512)" json:"description"` + Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` + CreatedAt types.Time `json:"created_at"` + UpdatedAt types.Time `json:"updated_at"` } func (Role) TableName() string { return "admin_roles" } @@ -40,15 +40,15 @@ func (Role) TableName() string { return "admin_roles" } // Permission 是后台权限,分接口(api)与菜单(menu)两类。 // 接口权限 code 对应后端 API 守卫;菜单权限 code 用于前端动态渲染菜单树。 type Permission struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - Code string `gorm:"column:code;type:varchar(128);uniqueIndex;not null" json:"code"` - Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` - Type string `gorm:"column:type;type:varchar(16);not null;default:'api'" json:"type"` - Method string `gorm:"column:method;type:varchar(16)" json:"method"` - Path string `gorm:"column:path;type:varchar(255)" json:"path"` - ParentID uint64 `gorm:"column:parent_id;index" json:"parent_id"` - Sort int `gorm:"column:sort;not null;default:0" json:"sort"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"column:code;type:varchar(128);uniqueIndex;not null" json:"code"` + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` + Type string `gorm:"column:type;type:varchar(16);not null;default:'api'" json:"type"` + Method string `gorm:"column:method;type:varchar(16)" json:"method"` + Path string `gorm:"column:path;type:varchar(255)" json:"path"` + ParentID uint64 `gorm:"column:parent_id;index" json:"parent_id"` + Sort int `gorm:"column:sort;not null;default:0" json:"sort"` + CreatedAt types.Time `json:"created_at"` } func (Permission) TableName() string { return "admin_permissions" } diff --git a/internal/model/miniapp.go b/internal/model/miniapp.go index 1a8dc8c..71339c3 100644 --- a/internal/model/miniapp.go +++ b/internal/model/miniapp.go @@ -2,9 +2,9 @@ package model import ( - "time" - "gorm.io/gorm" + + "portal-mini-go/internal/pkg/types" ) // MiniProgram 是被后台纳管的微信小程序配置。 @@ -17,8 +17,8 @@ type MiniProgram struct { AppSecret string `gorm:"column:app_secret;type:varchar(128);not null" json:"-"` Status int8 `gorm:"type:tinyint;not null;default:1" json:"status"` Ext string `gorm:"type:text" json:"ext"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt types.Time `json:"created_at"` + UpdatedAt types.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } diff --git a/internal/model/user.go b/internal/model/user.go index f0dcf09..f35b58e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -3,20 +3,20 @@ // 各小程序用户互不关联(open_id 在不同 appid 下天然互不冲突,故无需额外隔离键)。 package model -import "time" +import "portal-mini-go/internal/pkg/types" // User 是小程序端用户。MiniID 关联被纳管的小程序(mini_programs.id), // 与 open_id 共同唯一标识一个用户:(mini_id, open_id)。 type User struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - MiniID uint64 `gorm:"column:mini_id;index:idx_mini_openid" json:"mini_id"` - OpenID string `gorm:"type:varchar(64);index:idx_mini_openid" json:"openid"` - UnionID string `gorm:"type:varchar(64);index" json:"union_id"` - Nickname string `gorm:"type:varchar(128)" json:"nickname"` - Avatar string `gorm:"type:varchar(512)" json:"avatar"` - Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + MiniID uint64 `gorm:"column:mini_id;index:idx_mini_openid" json:"mini_id"` + OpenID string `gorm:"type:varchar(64);index:idx_mini_openid" json:"openid"` + UnionID string `gorm:"type:varchar(64);index" json:"union_id"` + Nickname string `gorm:"type:varchar(128)" json:"nickname"` + Avatar string `gorm:"type:varchar(512)" json:"avatar"` + Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` + CreatedAt types.Time `json:"created_at"` + UpdatedAt types.Time `json:"updated_at"` } func (User) TableName() string { return "users" } diff --git a/internal/pkg/types/time.go b/internal/pkg/types/time.go new file mode 100644 index 0000000..cd5746f --- /dev/null +++ b/internal/pkg/types/time.go @@ -0,0 +1,104 @@ +// Package types 提供可复用的基础类型。 +package types + +import ( + "database/sql/driver" + "fmt" + "strings" + "time" + + "gorm.io/gorm/schema" +) + +// TimeFormat 是时间字段返回给前端的标准格式(Y-m-d H:i:s)。 +const TimeFormat = "2006-01-02 15:04:05" + +// Time 是对 time.Time 的封装:JSON 序列化输出 TimeFormat 格式, +// 同时实现 driver.Valuer / sql.Scanner 以及与 GORM 的 GormDataTypeInterface, +// 使其既能作为模型字段被 GORM 自动维护(CreatedAt/UpdatedAt),又能以 +// "2006-01-02 15:04:05" 字符串形式返回给前端。 +type Time struct { + time.Time +} + +// NewTime 基于 time.Time 构造 Time。 +func NewTime(t time.Time) Time { return Time{Time: t} } + +// Now 返回当前时间的 Time 封装。 +func Now() Time { return Time{Time: time.Now()} } + +// GormDataType 让 GORM 将该字段识别为时间类型,从而启用自动时间戳。 +func (Time) GormDataType() schema.DataType { return schema.Time } + +func (t Time) MarshalJSON() ([]byte, error) { + if t.Time.IsZero() { + return []byte(`""`), nil + } + return []byte(`"` + t.Time.Format(TimeFormat) + `"`), nil +} + +// UnmarshalJSON 解析前端传入的时间字符串,兼容 TimeFormat 与 RFC3339。 +func (t *Time) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + t.Time = time.Time{} + return nil + } + parsed, err := time.ParseInLocation(TimeFormat, s, time.Local) + if err != nil { + if parsed, err = time.Parse(time.RFC3339, s); err != nil { + return fmt.Errorf("types.Time: 解析 %q 失败: %w", s, err) + } + } + t.Time = parsed + return nil +} + +// Value 实现 driver.Valuer,写入数据库时返回底层 time.Time。 +func (t Time) Value() (driver.Value, error) { + if t.Time.IsZero() { + return nil, nil + } + return t.Time, nil +} + +// Scan 实现 sql.Scanner,从数据库读取时还原为 time.Time。 +func (t *Time) Scan(src interface{}) error { + if src == nil { + t.Time = time.Time{} + return nil + } + switch v := src.(type) { + case time.Time: + t.Time = v + case *time.Time: + if v == nil { + t.Time = time.Time{} + } else { + t.Time = *v + } + case []byte: + parsed, err := time.ParseInLocation(TimeFormat, string(v), time.Local) + if err != nil { + return fmt.Errorf("types.Time: 扫描 []byte %q 失败: %w", string(v), err) + } + t.Time = parsed + case string: + parsed, err := time.ParseInLocation(TimeFormat, v, time.Local) + if err != nil { + return fmt.Errorf("types.Time: 扫描 string %q 失败: %w", v, err) + } + t.Time = parsed + default: + return fmt.Errorf("types.Time: 不支持的扫描类型 %T", src) + } + return nil +} + +// String 返回 TimeFormat 格式的字符串表示。 +func (t Time) String() string { + if t.Time.IsZero() { + return "" + } + return t.Time.Format(TimeFormat) +} diff --git a/internal/pkg/types/time_test.go b/internal/pkg/types/time_test.go new file mode 100644 index 0000000..aa724b2 --- /dev/null +++ b/internal/pkg/types/time_test.go @@ -0,0 +1,113 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/schema" +) + +func TestTime_MarshalJSON(t *testing.T) { + t.Run("正常时间输出 Y-m-d H:i:s", func(t *testing.T) { + tm := NewTime(time.Date(2026, 10, 26, 15, 4, 5, 0, time.UTC)) + b, err := json.Marshal(tm) + require.NoError(t, err) + assert.JSONEq(t, `"2026-10-26 15:04:05"`, string(b)) + }) + + t.Run("零值输出空字符串", func(t *testing.T) { + b, err := json.Marshal(Time{}) + require.NoError(t, err) + assert.JSONEq(t, `""`, string(b)) + }) +} + +func TestTime_UnmarshalJSON(t *testing.T) { + t.Run("Y-m-d H:i:s", func(t *testing.T) { + var tm Time + require.NoError(t, json.Unmarshal([]byte(`"2026-10-26 15:04:05"`), &tm)) + assert.Equal(t, time.Date(2026, 10, 26, 15, 4, 5, 0, time.Local), tm.Time) + }) + + t.Run("RFC3339 兼容", func(t *testing.T) { + var tm Time + require.NoError(t, json.Unmarshal([]byte(`"2026-10-26T15:04:05Z"`), &tm)) + assert.Equal(t, 2026, tm.Year()) + assert.Equal(t, time.October, tm.Month()) + }) + + t.Run("空字符串与 null", func(t *testing.T) { + for _, in := range []string{`""`, `null`} { + var tm Time + require.NoError(t, json.Unmarshal([]byte(in), &tm)) + assert.True(t, tm.IsZero()) + } + }) + + t.Run("非法格式报错", func(t *testing.T) { + var tm Time + assert.Error(t, json.Unmarshal([]byte(`"not-a-time"`), &tm)) + }) +} + +func TestTime_Scan(t *testing.T) { + src := time.Date(2026, 10, 26, 15, 4, 5, 0, time.UTC) + + cases := []struct { + name string + in interface{} + want time.Time + }{ + {"time.Time", src, src}, + {"*time.Time", &src, src}, + {"[]byte", []byte("2026-10-26 15:04:05"), time.Date(2026, 10, 26, 15, 4, 5, 0, time.Local)}, + {"string", "2026-10-26 15:04:05", time.Date(2026, 10, 26, 15, 4, 5, 0, time.Local)}, + {"nil", nil, time.Time{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var tm Time + require.NoError(t, tm.Scan(c.in)) + if c.want.IsZero() { + assert.True(t, tm.IsZero()) + return + } + assert.True(t, c.want.Equal(tm.Time), "got %v want %v", tm.Time, c.want) + }) + } + + t.Run("不支持的类型报错", func(t *testing.T) { + var tm Time + assert.Error(t, tm.Scan(123)) + }) +} + +func TestTime_Value(t *testing.T) { + src := time.Date(2026, 10, 26, 15, 4, 5, 0, time.UTC) + v, err := NewTime(src).Value() + require.NoError(t, err) + got, ok := v.(time.Time) + require.True(t, ok) + assert.True(t, src.Equal(got)) + + // 零值返回 nil。 + v, err = Time{}.Value() + require.NoError(t, err) + assert.Nil(t, v) +} + +func TestTime_GormDataType(t *testing.T) { + assert.Equal(t, schema.Time, Time{}.GormDataType()) +} + +// 确保 Time 满足相关接口,编译期校验。 +var ( + _ driver.Valuer = Time{} + _ interface{ Scan(interface{}) error } = (*Time)(nil) + _ json.Marshaler = Time{} + _ json.Unmarshaler = (*Time)(nil) +) diff --git a/internal/service/admin/miniapp/miniapp_service.go b/internal/service/admin/miniapp/miniapp_service.go index c278b3d..5c5c894 100644 --- a/internal/service/admin/miniapp/miniapp_service.go +++ b/internal/service/admin/miniapp/miniapp_service.go @@ -117,6 +117,19 @@ func (s *Service) Delete(ctx context.Context, id uint64) error { return s.repo.Delete(ctx, id) } +// StatusReq 更新小程序状态请求。状态仅允许 0(禁用)或 1(启用)。 +type StatusReq struct { + Status int8 `json:"status" binding:"required,oneof=0 1"` +} + +// UpdateStatus 按 ID 更新小程序状态(0 禁用、1 启用),返回更新后的小程序。 +func (s *Service) UpdateStatus(ctx context.Context, id uint64, status int8) (*model.MiniProgram, error) { + if err := s.repo.Update(ctx, id, map[string]interface{}{"status": status}); err != nil { + return nil, err + } + return s.repo.GetByID(ctx, id) +} + // validateExt 校验扩展字段为合法 JSON(允许为空字符串)。 func validateExt(s string) error { if s == "" {