From f5304fae80e6b0baab7fe9e6bf471e92fd53529f Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 25 Aug 2023 12:31:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20api=EB=A5=BC=20maingate=20?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/api.go | 34 +++++ core/api_coupon.go | 372 +++++++++++++++++++++++++++++++++++++++++++++ core/maingate.go | 6 + go.mod | 2 +- go.sum | 2 + 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 core/api_coupon.go diff --git a/core/api.go b/core/api.go index 90f5f1b..6dc17aa 100644 --- a/core/api.go +++ b/core/api.go @@ -342,6 +342,38 @@ func (caller apiCaller) maintenanceAPI(w http.ResponseWriter, r *http.Request) e return nil } +func (caller apiCaller) couponAPI(w http.ResponseWriter, r *http.Request) error { + switch r.Method { + case "PUT": + // 쿠폰 생성 + logger.Println("begin generateCoupons") + generateCoupons(caller.mg.mongoClient, w, r) + + case "POST": + // TODO : 쿠폰 사용 + // 쿠폰 사용 표시 해주고 내용을 응답 + logger.Println("begin useCoupon") + useCoupon(caller.mg.mongoClient, w, r) + + case "GET": + // 쿠폰 조회 + if r.Form.Has("code") { + // 쿠폰 코드 조회 + logger.Println("begin queryCoupon") + queryCoupon(caller.mg.mongoClient, w, r) + } else if r.Form.Has("name") { + // 쿠폰 코드 다운 + logger.Println("begin downloadCoupons") + downloadCoupons(caller.mg.mongoClient, w, r) + } else { + // 쿠폰 이름 목록 + logger.Println("begin listAllCouponNames") + listAllCouponNames(caller.mg.mongoClient, w, r) + } + } + return nil +} + var errApiTokenMissing = errors.New("mg-x-api-token is missing") func (caller apiCaller) configAPI(w http.ResponseWriter, r *http.Request) error { @@ -464,6 +496,8 @@ func (mg *Maingate) api(w http.ResponseWriter, r *http.Request) { err = caller.filesAPI(w, r) } else if strings.HasSuffix(r.URL.Path, "/block") { err = caller.blockAPI(w, r) + } else if strings.HasSuffix(r.URL.Path, "/coupon") { + err = caller.couponAPI(w, r) } if err != nil { diff --git a/core/api_coupon.go b/core/api_coupon.go new file mode 100644 index 0000000..9a7e396 --- /dev/null +++ b/core/api_coupon.go @@ -0,0 +1,372 @@ +package core + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" + "repositories.action2quare.com/ayo/gocommon" + coupon "repositories.action2quare.com/ayo/gocommon/coupon" + "repositories.action2quare.com/ayo/gocommon/logger" +) + +const ( + CollectionCoupon = gocommon.CollectionName("coupon") + CollectionCouponUse = gocommon.CollectionName("coupon_use") +) + +type couponDoc struct { + Name string `json:"name" bson:"name"` + Effect string `json:"effect" bson:"effect"` + Desc string `json:"desc" bson:"desc"` + Total int64 `json:"total" bson:"total"` + Remains []string `json:"remains,omitempty" bson:"remains,omitempty"` + Used []string `json:"used,omitempty" bson:"used,omitempty"` +} + +func makeCouponKey(roundnum uint32, uid []byte) string { + left := binary.BigEndian.Uint16(uid[0:2]) + right := binary.BigEndian.Uint16(uid[2:4]) + multi := uint32(left) * uint32(right) + xor := roundnum ^ multi + + final := make([]byte, 8) + binary.LittleEndian.PutUint32(final, xor) + copy(final[4:], uid) + return fmt.Sprintf("%s-%s-%s-%s", hex.EncodeToString(final[0:2]), hex.EncodeToString(final[2:4]), hex.EncodeToString(final[4:6]), hex.EncodeToString(final[6:8])) +} + +func makeCouponCodes(name string, count int) (string, map[string]string) { + checkunique := make(map[string]bool) + keys := make(map[string]string) + uid := make([]byte, 4) + + roundHash, roundnum := coupon.MakeCouponRoundHash(name) + seed := time.Now().UnixNano() + + for len(keys) < count { + rand.Seed(seed) + rand.Read(uid) + + code := makeCouponKey(roundnum, uid) + + if _, ok := checkunique[code]; !ok { + checkunique[code] = true + keys[hex.EncodeToString(uid)] = code + } + seed = int64(binary.BigEndian.Uint32(uid)) + } + return roundHash, keys +} + +func generateCoupons(mongoClient gocommon.MongoClient, w http.ResponseWriter, r *http.Request) { + name, _ := gocommon.ReadStringFormValue(r.Form, "name") + effect, _ := gocommon.ReadStringFormValue(r.Form, "effect") + count, _ := gocommon.ReadIntegerFormValue(r.Form, "count") + desc, _ := gocommon.ReadStringFormValue(r.Form, "desc") + + if count == 0 { + logger.Println("[generateCoupons] count == 0") + w.WriteHeader(http.StatusBadRequest) + return + } + + roundHash, _ := coupon.MakeCouponRoundHash(name) + roundObj, _ := primitive.ObjectIDFromHex(roundHash + roundHash + roundHash) + + if count < 0 { + // 무한 쿠폰이므로 그냥 문서 생성해 주고 끝 + if _, _, err := mongoClient.Update(CollectionCoupon, bson.M{ + "_id": roundObj, + }, bson.M{ + "$set": &couponDoc{ + Name: name, + Effect: effect, + Desc: desc, + Total: -1, + }, + }, options.Update().SetUpsert(true)); err != nil { + logger.Println("[generateCoupons] Update failed :", err) + w.WriteHeader(http.StatusInternalServerError) + } + return + } + + // effect가 비어있으면 기존의 roundName에 갯수를 추가해 준다 + // effect가 비어있지 않으면 roundName이 겹쳐서는 안된다. + coupondoc, err := mongoClient.FindOne(CollectionCoupon, bson.M{"_id": roundObj}) + if err != nil { + logger.Println("[generateCoupons] FindOne failed :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + lastKeys := make(map[string]bool) + if coupondoc != nil { + if r, ok := coupondoc["remains"]; ok { + remains := r.(primitive.A) + for _, uid := range remains { + lastKeys[uid.(string)] = true + } + } + } + + issuedKeys := make(map[string]string) + for len(issuedKeys) < int(count) { + _, vs := makeCouponCodes(name, int(count)-len(issuedKeys)) + for k, v := range vs { + if _, ok := lastKeys[k]; !ok { + // 기존 키와 중복되지 않는 것만 + issuedKeys[k] = v + } + } + } + + var coupons []string + var uids []string + for uid, code := range issuedKeys { + uids = append(uids, uid) + coupons = append(coupons, code) + } + + if coupondoc != nil { + _, _, err = mongoClient.Update(CollectionCoupon, bson.M{ + "_id": roundObj, + }, bson.M{ + "$push": bson.M{"remains": bson.M{"$each": uids}}, + "$inc": bson.M{"total": count}, + }, options.Update().SetUpsert(true)) + } else { + _, _, err = mongoClient.Update(CollectionCoupon, bson.M{ + "_id": roundObj, + }, bson.M{ + "$push": bson.M{"remains": bson.M{"$each": uids}}, + "$set": couponDoc{ + Name: name, + Effect: effect, + Desc: desc, + Total: count, + }, + }, options.Update().SetUpsert(true)) + } + + if err != nil { + logger.Println("[generateCoupons] Update failed :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + enc := json.NewEncoder(w) + enc.Encode(coupons) +} + +func downloadCoupons(mongoClient gocommon.MongoClient, w http.ResponseWriter, r *http.Request) { + name, _ := gocommon.ReadStringFormValue(r.Form, "name") + if len(name) == 0 { + logger.Println("[downloadCoupons] name is empty") + w.WriteHeader(http.StatusBadRequest) + return + } + + round, _ := coupon.MakeCouponRoundHash(name) + + roundObj, err := primitive.ObjectIDFromHex(round + round + round) + if err != nil { + // 유효하지 않은 형식의 code + logger.Println("[downloadCoupons] ObjectIDFromHex failed :", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + var coupon couponDoc + if err := mongoClient.FindOneAs(CollectionCoupon, bson.M{ + "_id": roundObj, + }, &coupon, options.FindOne().SetProjection(bson.M{"_id": 0, "remains": 1})); err != nil { + logger.Println("[downloadCoupons] FindOne failed :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + roundnum := binary.BigEndian.Uint32(roundObj[:]) + var coupons []string + for _, uid := range coupon.Remains { + coupons = append(coupons, makeCouponKey(roundnum, []byte(uid))) + } + + enc := json.NewEncoder(w) + enc.Encode(coupons) +} + +func queryCoupon(mongoClient gocommon.MongoClient, w http.ResponseWriter, r *http.Request) { + code, _ := gocommon.ReadStringFormValue(r.Form, "code") + if len(code) == 0 { + logger.Println("[queryCoupon] code is empty") + w.WriteHeader(http.StatusBadRequest) + return + } + + round, _ := coupon.DisolveCouponCode(code) + if len(round) == 0 { + // 유효하지 않은 형식의 code + // 쿠폰 이름일 수 있으므로 round hash를 계산한다. + round, _ = coupon.MakeCouponRoundHash(code) + } + + roundObj, err := primitive.ObjectIDFromHex(round + round + round) + if err != nil { + // 유효하지 않은 형식의 code + logger.Println("[queryCoupon] ObjectIDFromHex failed :", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + var coupon couponDoc + if err := mongoClient.FindOneAs(CollectionCoupon, bson.M{ + "_id": roundObj, + }, &coupon, options.FindOne().SetProjection(bson.M{"_id": 0, "effect": 1, "name": 1, "reason": 1, "total": 1, "remains": 0, "used": 0})); err != nil { + logger.Println("[queryCoupon] FindOneAs failed :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + enc := json.NewEncoder(w) + enc.Encode(coupon) +} + +func listAllCouponNames(mongoClient gocommon.MongoClient, w http.ResponseWriter, r *http.Request) { + all, err := mongoClient.FindAll(CollectionCoupon, bson.M{}, options.Find().SetProjection(bson.M{"name": 1})) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + var names []string + for _, doc := range all { + names = append(names, doc["name"].(string)) + } + + enc := json.NewEncoder(w) + enc.Encode(names) +} + +func useCoupon(mongoClient gocommon.MongoClient, w http.ResponseWriter, r *http.Request) { + acc, ok := gocommon.ReadObjectIDFormValue(r.Form, "accid") + if !ok || acc.IsZero() { + w.WriteHeader(http.StatusBadRequest) + return + } + code, _ := gocommon.ReadStringFormValue(r.Form, "code") + code = strings.TrimSpace(code) + if len(code) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + round, key := coupon.DisolveCouponCode(code) + if len(round) == 0 { + // couponId가 쿠폰 이름일 수도 있다. 무한 쿠폰 + round, _ = coupon.MakeCouponRoundHash(code) + } + + // 1. 내가 이 라운드의 쿠폰을 쓴 적이 있나 + already, err := mongoClient.Exists(CollectionCouponUse, bson.M{ + "_id": acc, + "rounds": round, + }) + if err != nil { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if already { + // 이미 이 라운드의 쿠폰을 사용한 적이 있다. + w.WriteHeader(http.StatusConflict) + return + } + + var coupon couponDoc + roundObj, _ := primitive.ObjectIDFromHex(round + round + round) + if len(key) == 0 { + // 무한 쿠폰일 수 있으므로 존재하는지 확인 + if err := mongoClient.FindOneAs(CollectionCoupon, bson.M{ + "_id": roundObj, + }, &coupon, options.FindOne().SetProjection(bson.M{"_id": 0, "effect": 1, "name": 1, "reason": 1, "total": 1})); err != nil { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if coupon.Total > 0 { + // 무한 쿠폰 아니네? + w.WriteHeader(http.StatusBadRequest) + return + } + } else { + // 2. 쿠폰을 하나 꺼냄 + matched, _, err := mongoClient.Update(CollectionCoupon, bson.M{ + "_id": roundObj, + }, bson.M{ + "$pull": bson.M{"remains": key}, + }) + if err != nil { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if !matched { + // 쿠폰이 없다. + w.WriteHeader(http.StatusBadRequest) + return + } + + // 3. round의 효과 읽기 + if err := mongoClient.FindOneAndUpdateAs(CollectionCoupon, bson.M{ + "_id": roundObj, + }, bson.M{ + "$push": bson.M{"used": key}, + }, &coupon, options.FindOneAndUpdate().SetProjection(bson.M{"effect": 1})); err != nil { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } + + if len(coupon.Effect) == 0 { + // 쿠폰이 없네? + w.WriteHeader(http.StatusBadRequest) + return + } + + // 4. 쿠폰은 사용한 것으로 표시 + // 이제 이 아래에서 실패하면 이 쿠폰은 못쓴다. + updated, _, err := mongoClient.Update(CollectionCouponUse, bson.M{ + "_id": acc, + }, bson.M{ + "$push": bson.M{"rounds": round}, + "$set": bson.M{round + ".id": code}, + "$currentDate": bson.M{round + ".ts": true}, + }, options.Update().SetUpsert(true)) + + if err != nil { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if !updated { + logger.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Write([]byte(coupon.Effect)) +} diff --git a/core/maingate.go b/core/maingate.go index 4883098..460a6d3 100644 --- a/core/maingate.go +++ b/core/maingate.go @@ -305,6 +305,12 @@ func (mg *Maingate) prepare(context context.Context) (err error) { return makeErrorWithStack(err) } + if err = mg.mongoClient.MakeUniqueIndices(CollectionCouponUse, map[string]bson.D{ + "idrounds": {{Key: "_id", Value: 1}, {Key: "rounds", Value: 1}}, + }); err != nil { + return err + } + if err = mg.mongoClient.MakeUniqueIndices(CollectionAuth, map[string]bson.D{ "skonly": {{Key: "sk", Value: 1}}, }); err != nil { diff --git a/go.mod b/go.mod index 19a46d3..cc5ddb5 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/golang-jwt/jwt v3.2.2+incompatible go.mongodb.org/mongo-driver v1.11.7 google.golang.org/api v0.128.0 - repositories.action2quare.com/ayo/gocommon v0.0.0-20230823134414-400c7f644333 + repositories.action2quare.com/ayo/gocommon v0.0.0-20230825015501-e4527aa5b3ff ) require ( diff --git a/go.sum b/go.sum index 11d4856..ad51ef5 100644 --- a/go.sum +++ b/go.sum @@ -333,3 +333,5 @@ repositories.action2quare.com/ayo/gocommon v0.0.0-20230823084014-c34045e215fc h1 repositories.action2quare.com/ayo/gocommon v0.0.0-20230823084014-c34045e215fc/go.mod h1:PdpZ16O1czKKxCxn+0AFNaEX/0kssYwC3G8jR0V7ybw= repositories.action2quare.com/ayo/gocommon v0.0.0-20230823134414-400c7f644333 h1:3QWHeK6eX1yhaeN/Lu88N4B2ORb/PdBkXUS+HzFOWgU= repositories.action2quare.com/ayo/gocommon v0.0.0-20230823134414-400c7f644333/go.mod h1:PdpZ16O1czKKxCxn+0AFNaEX/0kssYwC3G8jR0V7ybw= +repositories.action2quare.com/ayo/gocommon v0.0.0-20230825015501-e4527aa5b3ff h1:nTOqgPSfm0EANR1SFAi+Zi/KErAAlstVcEWWOnyDT5g= +repositories.action2quare.com/ayo/gocommon v0.0.0-20230825015501-e4527aa5b3ff/go.mod h1:PdpZ16O1czKKxCxn+0AFNaEX/0kssYwC3G8jR0V7ybw=