| @@ -87,6 +87,7 @@ func AddRedemption(c *gin.Context) { | |||
| cleanRedemption := model.Redemption{ | |||
| UserId: c.GetInt("id"), | |||
| Name: redemption.Name, | |||
| Remark: redemption.Remark, | |||
| Key: key, | |||
| CreatedTime: common.GetTimestamp(), | |||
| Quota: redemption.Quota, | |||
| @@ -146,6 +147,7 @@ func UpdateRedemption(c *gin.Context) { | |||
| } | |||
| // If you add more fields, please also update redemption.Update() | |||
| cleanRedemption.Name = redemption.Name | |||
| cleanRedemption.Remark = redemption.Remark | |||
| cleanRedemption.Quota = redemption.Quota | |||
| cleanRedemption.ExpiredTime = redemption.ExpiredTime | |||
| } | |||
| @@ -0,0 +1,159 @@ | |||
| package controller | |||
| import ( | |||
| "bytes" | |||
| "encoding/json" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupRedemptionControllerDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, _ := db.DB() | |||
| sqlDB.SetMaxOpenConns(1) | |||
| origDB := model.DB | |||
| origLogDB := model.LOG_DB | |||
| model.DB = db | |||
| model.LOG_DB = db | |||
| common.UsingSQLite = true | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.AutoMigrate(&model.User{}, &model.Redemption{}, &model.Log{})) | |||
| t.Cleanup(func() { | |||
| model.DB = origDB | |||
| model.LOG_DB = origLogDB | |||
| _ = sqlDB.Close() | |||
| }) | |||
| return db | |||
| } | |||
| func setupRedemptionRouter() *gin.Engine { | |||
| gin.SetMode(gin.TestMode) | |||
| r := gin.New() | |||
| r.Use(func(c *gin.Context) { | |||
| c.Set("id", 1) | |||
| c.Next() | |||
| }) | |||
| g := r.Group("/api/redemption") | |||
| g.POST("/", AddRedemption) | |||
| g.PUT("/", UpdateRedemption) | |||
| return r | |||
| } | |||
| func TestAddRedemptionStoresRemark(t *testing.T) { | |||
| db := setupRedemptionControllerDB(t) | |||
| router := setupRedemptionRouter() | |||
| body, err := json.Marshal(map[string]interface{}{ | |||
| "name": "campaign-a", | |||
| "remark": "admin only note", | |||
| "quota": 500000, | |||
| "count": 1, | |||
| "expired_time": 0, | |||
| }) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/redemption/", bytes.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| assert.Equal(t, http.StatusOK, w.Code) | |||
| var rows []model.Redemption | |||
| require.NoError(t, db.Find(&rows).Error) | |||
| require.Len(t, rows, 1) | |||
| assert.Equal(t, "campaign-a", rows[0].Name) | |||
| assert.Equal(t, "admin only note", rows[0].Remark) | |||
| } | |||
| func TestUpdateRedemptionStoresRemark(t *testing.T) { | |||
| db := setupRedemptionControllerDB(t) | |||
| router := setupRedemptionRouter() | |||
| row := model.Redemption{ | |||
| Id: 1, | |||
| UserId: 1, | |||
| Key: "update-remark-key", | |||
| Name: "campaign-b", | |||
| Remark: "before update", | |||
| Status: common.RedemptionCodeStatusEnabled, | |||
| Quota: 500000, | |||
| CreatedTime: common.GetTimestamp(), | |||
| } | |||
| require.NoError(t, db.Create(&row).Error) | |||
| body, err := json.Marshal(map[string]interface{}{ | |||
| "id": 1, | |||
| "name": "campaign-b", | |||
| "remark": "after update", | |||
| "quota": 500000, | |||
| "expired_time": 0, | |||
| }) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPut, "/api/redemption/", bytes.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| assert.Equal(t, http.StatusOK, w.Code) | |||
| var stored model.Redemption | |||
| require.NoError(t, db.First(&stored, 1).Error) | |||
| assert.Equal(t, "after update", stored.Remark) | |||
| } | |||
| func TestUpdateRedemptionStatusOnlyKeepsRemark(t *testing.T) { | |||
| db := setupRedemptionControllerDB(t) | |||
| router := setupRedemptionRouter() | |||
| row := model.Redemption{ | |||
| Id: 1, | |||
| UserId: 1, | |||
| Key: "status-only-remark-key", | |||
| Name: "campaign-c", | |||
| Remark: "keep this remark", | |||
| Status: common.RedemptionCodeStatusEnabled, | |||
| Quota: 500000, | |||
| CreatedTime: common.GetTimestamp(), | |||
| } | |||
| require.NoError(t, db.Create(&row).Error) | |||
| body, err := json.Marshal(map[string]interface{}{ | |||
| "id": 1, | |||
| "status": common.RedemptionCodeStatusDisabled, | |||
| "remark": "should not overwrite", | |||
| }) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPut, "/api/redemption/?status_only=true", bytes.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| assert.Equal(t, http.StatusOK, w.Code) | |||
| var stored model.Redemption | |||
| require.NoError(t, db.First(&stored, 1).Error) | |||
| assert.Equal(t, common.RedemptionCodeStatusDisabled, stored.Status) | |||
| assert.Equal(t, "keep this remark", stored.Remark) | |||
| } | |||
| @@ -189,6 +189,7 @@ | |||
| | key | string | 兑换码(32字符,唯一) | | |||
| | status | int | 状态:1=启用,2=已使用,3=已禁用 | | |||
| | name | string | 兑换码名称 | | |||
| | remark | string | 备注 | | |||
| | quota | int | 额度值 | | |||
| | created_time | int64 | 创建时间 | | |||
| | redeemed_time | int64 | 兑换时间 | | |||
| @@ -20,7 +20,7 @@ type Redemption struct { | |||
| Key string `json:"key" gorm:"type:char(32);uniqueIndex"` | |||
| Status int `json:"status" gorm:"default:1"` | |||
| Name string `json:"name" gorm:"index"` | |||
| Remark string `json:"remark"` | |||
| Remark string `json:"remark" gorm:"index"` | |||
| Quota int `json:"quota" gorm:"default:100"` | |||
| CreatedTime int64 `json:"created_time" gorm:"bigint"` | |||
| RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` | |||
| @@ -276,7 +276,7 @@ func TestRedemptionUpdateRemarkPersistsRemark(t *testing.T) { | |||
| assert.Equal(t, "vip-updated", updated.Remark) | |||
| } | |||
| func TestSearchRedemptionsByRemarkRemark(t *testing.T) { | |||
| func TestSearchRedemptionsByRemark(t *testing.T) { | |||
| db := setupRedemptionDB(t) | |||
| require.NoError(t, db.Create(&Redemption{ | |||
| @@ -307,3 +307,35 @@ func TestSearchRedemptionsByRemarkRemark(t *testing.T) { | |||
| assert.Equal(t, 1, results[0].Id) | |||
| assert.Equal(t, "vip benefit", results[0].Remark) | |||
| } | |||
| func TestSearchRedemptionsByRemarkWithNumericKeyword(t *testing.T) { | |||
| db := setupRedemptionDB(t) | |||
| require.NoError(t, db.Create(&Redemption{ | |||
| Id: 1, | |||
| UserId: 1, | |||
| Key: "remark-search-numeric", | |||
| Status: common.RedemptionCodeStatusEnabled, | |||
| Name: "starter-pack", | |||
| Remark: "123-vip benefit", | |||
| Quota: 100, | |||
| CreatedTime: common.GetTimestamp(), | |||
| }).Error) | |||
| require.NoError(t, db.Create(&Redemption{ | |||
| Id: 2, | |||
| UserId: 1, | |||
| Key: "remark-search-other-numeric", | |||
| Status: common.RedemptionCodeStatusEnabled, | |||
| Name: "basic-pack", | |||
| Remark: "standard benefit", | |||
| Quota: 100, | |||
| CreatedTime: common.GetTimestamp(), | |||
| }).Error) | |||
| results, total, err := SearchRedemptions("123", 0, 10) | |||
| require.NoError(t, err) | |||
| require.Equal(t, int64(1), total) | |||
| require.Len(t, results, 1) | |||
| assert.Equal(t, 1, results[0].Id) | |||
| assert.Equal(t, "123-vip benefit", results[0].Remark) | |||
| } | |||
| @@ -27,9 +27,6 @@ import { | |||
| REDEMPTION_ACTIONS, | |||
| } from '../../../constants/redemption.constants'; | |||
| /** | |||
| * Check if redemption code is expired | |||
| */ | |||
| export const isExpired = (record) => { | |||
| return ( | |||
| record.status === REDEMPTION_STATUS.UNUSED && | |||
| @@ -38,16 +35,10 @@ export const isExpired = (record) => { | |||
| ); | |||
| }; | |||
| /** | |||
| * Render timestamp | |||
| */ | |||
| const renderTimestamp = (timestamp) => { | |||
| return <>{timestamp2string(timestamp)}</>; | |||
| }; | |||
| /** | |||
| * Render redemption code status | |||
| */ | |||
| const renderStatus = (status, record, t) => { | |||
| if (isExpired(record)) { | |||
| return ( | |||
| @@ -73,18 +64,12 @@ const renderStatus = (status, record, t) => { | |||
| ); | |||
| }; | |||
| /** | |||
| * Get redemption code table column definitions | |||
| */ | |||
| export const getRedemptionsColumns = ({ | |||
| t, | |||
| manageRedemption, | |||
| copyText, | |||
| setEditingRedemption, | |||
| setShowEdit, | |||
| refresh, | |||
| redemptions, | |||
| activePage, | |||
| showDeleteRedemptionModal, | |||
| }) => { | |||
| return [ | |||
| @@ -96,6 +81,17 @@ export const getRedemptionsColumns = ({ | |||
| title: t('名称'), | |||
| dataIndex: 'name', | |||
| }, | |||
| { | |||
| title: t('备注'), | |||
| dataIndex: 'remark', | |||
| render: (text) => { | |||
| return ( | |||
| <div style={{ whiteSpace: 'pre-wrap', minWidth: 180 }}> | |||
| {text || ''} | |||
| </div> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| title: t('状态'), | |||
| dataIndex: 'status', | |||
| @@ -111,7 +107,7 @@ export const getRedemptionsColumns = ({ | |||
| return ( | |||
| <div> | |||
| <Tag color='grey' shape='circle'> | |||
| {renderQuota(parseInt(text))} | |||
| {renderQuota(parseInt(text, 10))} | |||
| </Tag> | |||
| </div> | |||
| ); | |||
| @@ -132,7 +128,7 @@ export const getRedemptionsColumns = ({ | |||
| }, | |||
| }, | |||
| { | |||
| title: t('兑换人ID'), | |||
| title: t('使用用户ID'), | |||
| dataIndex: 'used_user_id', | |||
| render: (text) => { | |||
| return <div>{text === 0 ? t('无') : text}</div>; | |||
| @@ -144,7 +140,6 @@ export const getRedemptionsColumns = ({ | |||
| fixed: 'right', | |||
| width: 205, | |||
| render: (text, record) => { | |||
| // Create dropdown menu items for more operations | |||
| const moreMenuItems = [ | |||
| { | |||
| node: 'item', | |||
| @@ -60,6 +60,7 @@ const EditRedemptionModal = (props) => { | |||
| const getInitValues = () => ({ | |||
| name: '', | |||
| remark: '', | |||
| quota: 100000, | |||
| count: 1, | |||
| expired_time: null, | |||
| @@ -261,6 +262,16 @@ const EditRedemptionModal = (props) => { | |||
| showClear | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.TextArea | |||
| field='remark' | |||
| label={t('备注')} | |||
| placeholder={t('请输入备注(仅管理员可见)')} | |||
| autosize={{ minRows: 3, maxRows: 6 }} | |||
| style={{ width: '100%' }} | |||
| showClear | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| </Card> | |||