| @@ -87,6 +87,7 @@ func AddRedemption(c *gin.Context) { | |||||
| cleanRedemption := model.Redemption{ | cleanRedemption := model.Redemption{ | ||||
| UserId: c.GetInt("id"), | UserId: c.GetInt("id"), | ||||
| Name: redemption.Name, | Name: redemption.Name, | ||||
| Remark: redemption.Remark, | |||||
| Key: key, | Key: key, | ||||
| CreatedTime: common.GetTimestamp(), | CreatedTime: common.GetTimestamp(), | ||||
| Quota: redemption.Quota, | Quota: redemption.Quota, | ||||
| @@ -146,6 +147,7 @@ func UpdateRedemption(c *gin.Context) { | |||||
| } | } | ||||
| // If you add more fields, please also update redemption.Update() | // If you add more fields, please also update redemption.Update() | ||||
| cleanRedemption.Name = redemption.Name | cleanRedemption.Name = redemption.Name | ||||
| cleanRedemption.Remark = redemption.Remark | |||||
| cleanRedemption.Quota = redemption.Quota | cleanRedemption.Quota = redemption.Quota | ||||
| cleanRedemption.ExpiredTime = redemption.ExpiredTime | 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字符,唯一) | | | key | string | 兑换码(32字符,唯一) | | ||||
| | status | int | 状态:1=启用,2=已使用,3=已禁用 | | | status | int | 状态:1=启用,2=已使用,3=已禁用 | | ||||
| | name | string | 兑换码名称 | | | name | string | 兑换码名称 | | ||||
| | remark | string | 备注 | | |||||
| | quota | int | 额度值 | | | quota | int | 额度值 | | ||||
| | created_time | int64 | 创建时间 | | | created_time | int64 | 创建时间 | | ||||
| | redeemed_time | int64 | 兑换时间 | | | redeemed_time | int64 | 兑换时间 | | ||||
| @@ -20,7 +20,7 @@ type Redemption struct { | |||||
| Key string `json:"key" gorm:"type:char(32);uniqueIndex"` | Key string `json:"key" gorm:"type:char(32);uniqueIndex"` | ||||
| Status int `json:"status" gorm:"default:1"` | Status int `json:"status" gorm:"default:1"` | ||||
| Name string `json:"name" gorm:"index"` | Name string `json:"name" gorm:"index"` | ||||
| Remark string `json:"remark"` | |||||
| Remark string `json:"remark" gorm:"index"` | |||||
| Quota int `json:"quota" gorm:"default:100"` | Quota int `json:"quota" gorm:"default:100"` | ||||
| CreatedTime int64 `json:"created_time" gorm:"bigint"` | CreatedTime int64 `json:"created_time" gorm:"bigint"` | ||||
| RedeemedTime int64 `json:"redeemed_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) | assert.Equal(t, "vip-updated", updated.Remark) | ||||
| } | } | ||||
| func TestSearchRedemptionsByRemarkRemark(t *testing.T) { | |||||
| func TestSearchRedemptionsByRemark(t *testing.T) { | |||||
| db := setupRedemptionDB(t) | db := setupRedemptionDB(t) | ||||
| require.NoError(t, db.Create(&Redemption{ | 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, 1, results[0].Id) | ||||
| assert.Equal(t, "vip benefit", results[0].Remark) | 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, | REDEMPTION_ACTIONS, | ||||
| } from '../../../constants/redemption.constants'; | } from '../../../constants/redemption.constants'; | ||||
| /** | |||||
| * Check if redemption code is expired | |||||
| */ | |||||
| export const isExpired = (record) => { | export const isExpired = (record) => { | ||||
| return ( | return ( | ||||
| record.status === REDEMPTION_STATUS.UNUSED && | record.status === REDEMPTION_STATUS.UNUSED && | ||||
| @@ -38,16 +35,10 @@ export const isExpired = (record) => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| /** | |||||
| * Render timestamp | |||||
| */ | |||||
| const renderTimestamp = (timestamp) => { | const renderTimestamp = (timestamp) => { | ||||
| return <>{timestamp2string(timestamp)}</>; | return <>{timestamp2string(timestamp)}</>; | ||||
| }; | }; | ||||
| /** | |||||
| * Render redemption code status | |||||
| */ | |||||
| const renderStatus = (status, record, t) => { | const renderStatus = (status, record, t) => { | ||||
| if (isExpired(record)) { | if (isExpired(record)) { | ||||
| return ( | return ( | ||||
| @@ -73,18 +64,12 @@ const renderStatus = (status, record, t) => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| /** | |||||
| * Get redemption code table column definitions | |||||
| */ | |||||
| export const getRedemptionsColumns = ({ | export const getRedemptionsColumns = ({ | ||||
| t, | t, | ||||
| manageRedemption, | manageRedemption, | ||||
| copyText, | copyText, | ||||
| setEditingRedemption, | setEditingRedemption, | ||||
| setShowEdit, | setShowEdit, | ||||
| refresh, | |||||
| redemptions, | |||||
| activePage, | |||||
| showDeleteRedemptionModal, | showDeleteRedemptionModal, | ||||
| }) => { | }) => { | ||||
| return [ | return [ | ||||
| @@ -96,6 +81,17 @@ export const getRedemptionsColumns = ({ | |||||
| title: t('名称'), | title: t('名称'), | ||||
| dataIndex: 'name', | dataIndex: 'name', | ||||
| }, | }, | ||||
| { | |||||
| title: t('备注'), | |||||
| dataIndex: 'remark', | |||||
| render: (text) => { | |||||
| return ( | |||||
| <div style={{ whiteSpace: 'pre-wrap', minWidth: 180 }}> | |||||
| {text || ''} | |||||
| </div> | |||||
| ); | |||||
| }, | |||||
| }, | |||||
| { | { | ||||
| title: t('状态'), | title: t('状态'), | ||||
| dataIndex: 'status', | dataIndex: 'status', | ||||
| @@ -111,7 +107,7 @@ export const getRedemptionsColumns = ({ | |||||
| return ( | return ( | ||||
| <div> | <div> | ||||
| <Tag color='grey' shape='circle'> | <Tag color='grey' shape='circle'> | ||||
| {renderQuota(parseInt(text))} | |||||
| {renderQuota(parseInt(text, 10))} | |||||
| </Tag> | </Tag> | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -132,7 +128,7 @@ export const getRedemptionsColumns = ({ | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| title: t('兑换人ID'), | |||||
| title: t('使用用户ID'), | |||||
| dataIndex: 'used_user_id', | dataIndex: 'used_user_id', | ||||
| render: (text) => { | render: (text) => { | ||||
| return <div>{text === 0 ? t('无') : text}</div>; | return <div>{text === 0 ? t('无') : text}</div>; | ||||
| @@ -144,7 +140,6 @@ export const getRedemptionsColumns = ({ | |||||
| fixed: 'right', | fixed: 'right', | ||||
| width: 205, | width: 205, | ||||
| render: (text, record) => { | render: (text, record) => { | ||||
| // Create dropdown menu items for more operations | |||||
| const moreMenuItems = [ | const moreMenuItems = [ | ||||
| { | { | ||||
| node: 'item', | node: 'item', | ||||
| @@ -60,6 +60,7 @@ const EditRedemptionModal = (props) => { | |||||
| const getInitValues = () => ({ | const getInitValues = () => ({ | ||||
| name: '', | name: '', | ||||
| remark: '', | |||||
| quota: 100000, | quota: 100000, | ||||
| count: 1, | count: 1, | ||||
| expired_time: null, | expired_time: null, | ||||
| @@ -261,6 +262,16 @@ const EditRedemptionModal = (props) => { | |||||
| showClear | showClear | ||||
| /> | /> | ||||
| </Col> | </Col> | ||||
| <Col span={24}> | |||||
| <Form.TextArea | |||||
| field='remark' | |||||
| label={t('备注')} | |||||
| placeholder={t('请输入备注(仅管理员可见)')} | |||||
| autosize={{ minRows: 3, maxRows: 6 }} | |||||
| style={{ width: '100%' }} | |||||
| showClear | |||||
| /> | |||||
| </Col> | |||||
| </Row> | </Row> | ||||
| </Card> | </Card> | ||||