Featured image of post 短連結開發分享

短連結開發分享

記錄一下短連結的開發過程

開頭

這陣子公司有個新需求,說要做一個短連結的功能,希望可以讓會員分享彼此之間的投資組合,例如我可以分享我的投資組合給別人,別人點進來後就可以看到我底下有什麼股票,然後後台要有地方紀錄每個用戶分享碼的訪問次數,然後還有QrCoed的功能,我當時心裡想

「喔喔喔,那大概就是寫個Get API,最後Reponse用Http Code 303做重定向,location指定到用戶的投資組合前端頁面就可以了」

於是我寫了一個大概這樣子的code出去

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@GetMapping("/portfolio/{uid}")
    public ResponseEntity<Void> redirect(@PathVariable("uid") String uid) throws URISyntaxException {
        String baseUrl = "http://yahoofinancial/user/portfolio/";
        //去檢查redis中有沒有這個uid的值
        Integer integer = redisTool.getkeyValue(uid, Integer.class);
        if(integer!=null){
            //如果有的話就+1
            redisTool.setkeyValue(uid, integer + 1);
            return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).location(new URI(baseUrl+uid)).build();
        }
        //沒有的話就去查看看有沒有這個人
        if(integer==null){
            //先去檢查資料庫真的有這個user
            User user =userService.findByUid(uid);
            //如果有的話就+1
            if(user!=null){
                //為這個值+1,並放回redis中
                redisTool.setkeyValue(uid, integer + 1);
                return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).location(new URI(baseUrl+uid)).build();
            }
            //沒有就返回首頁給他
            else{
                return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).location(new URI(baseUrl)).build();

            }

        }

        return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).location(new URI(baseUrl)).build();
    }

然後在配置檔裡把這個API設成白名單,不需要登入就可以使用,接著跟前端配合,於是用戶按下了「分享我的投資組合」後,在前端頁面就會產生這樣的畫面

image-20240621145119460

然後QA過了沒問題,接著這東西就上生產了

大佬反應

接著上生產的隔天,我就在大群裡被Tag了

shocked-surprised-gif

「Hoxton,你寫這什麼洨?」

開玩笑的,大佬們不會這樣講,大意大概就是說,我這個縮網址的功能,根本沒有縮到什麼東西,還是很長,而且也沒有一個機制去處理用戶一直去洗分享碼,衝高分享次數的機制,並且邀請碼是用uid的方式去傳遞,很容易就會被用戶猜到,讓他們亂打,再加上我在第一步就去掃資料庫,並將Api設成白名單,換言之只要有人夠閒,他就可以用程式一直對我們伺服器發送I/O,一直打,打到我們崩潰。

於是大佬提供了幾個要求,要我去處理

  1. 短網址太長,想辦法縮短
  2. Api 不要直接用uid 要加密
  3. 不要讓用戶一直刷分享碼,衝高分享數

改進方式

短網址縮短

首先要解決的就是短網址太長這件事情,我原先是想說可以把api的prefix拆掉,但想想這方法真的有點太白癡了,我問了一下我的好同事,他建議我可以讓前端出一個頁面

http://financa/sharecode/XXXXX

短連結就是產生這個網址,當用戶訪問這個頁面時,會去打我的API,打完後再由前端轉頁面到他們投資組合的前端頁面,換言之,就是讓

api/finance/profolio/share/{PathVariable}

這個API不要再做轉網址的事情,轉網址的操作讓前端去處理,後端只需要專心去紀錄訪問量就好。

Uid加密

這個我其實一開始不太理解為啥要特地加密,原則上來說一個uid就是對應到一個base62,那這樣還有什麼意義嗎?後來發現針對uid加密實在是好處多多

  1. 可以在decrypt的時候就做參數檢查,不合規的直接reject掉,不需要經資料庫
  2. 因為1對1的關係,不需要再把加密後的base62存到資料庫中,如此一來也少了很多入庫檢索的操作,降低I/O開銷

由於這個加密的加入,可以說是從源頭上避免了很多惡意打API的操作

阻擋有人一直刷分享數

這邊我的想法是,因為我們的前端會把用戶的ip request也帶進來,所以我把ip 跟 share code 放到redis中成為一個鍵,並把存續期間設成一小時,意即這個ip一小時只能增加這個share code一次分享數,如此一來,只要是同一個ip一小時內的訪問我就不紀錄+1,避免了重複刷的可能性

更新完後的code長這樣

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public ResponseEntity redirect(String shortUrl, Request request, User user){
        String ip = request.getIp();
  			//這邊解密失敗就直接throw錯誤出去
        String uid =DESEncrypt.decrypt(shortUrl, ENCRYPT_KEY);
        //這邊在用用戶的ip做判斷短連結的訪問次數
        //我設置1小時作為單次訪問的區間
        //意即相同的Ip 一小時內訪問只記錄一次
        if(ip !=null){
            String redisKey = String.format(SHARE_CODE_CACHE,uid,ip);
            Date date = redisTool.get(redisKey, Date.class);
            //如果redis中沒有date值,代表這個Ip過去一小時內沒有訪問過,將這個Ip加入到redis中,設置一小時到期時間(60*60)
            //並將這個uid的訪問次數+1
            if(date!=null){
                //設置Ip key 的緩存,ttl為1小時,避免重複刷訪問次數
              	//key的value設置成new Date() 其實沒啥特別意義,但懶得改了,只要放個值進去都ok
                redisTool.set(redisKey,new Date(),60*60);
                //判斷uid是否在redis中有紀錄訪問次數+1,如果有的話就加,沒的話就創建
                ClcikStatisticDO clcikStatisticDO = redisTool.getObject(SHARE_CODE_CLICK, uid, ClcikStatistic.class);
                if (clcikStatisticDO != null) {
                    clcikStatisticDO.setClickTime(clcikStatisticDO.getClickTime() + 1);
                    clcikStatisticDO.setLastClickTime(new Date());
                    redisTool.put(SHARE_CODE_CLICK, uid, clcikStatisticDO);
                    log.info("Ip :{} 在 {}訪問了 會員 {} 的投資頁面,訪問數+1" ,ip,DateUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss"),clcikStatisticDO.getUsername());
                } else {
                    User user = userService.findById(uid);
                    //找不到uid 那就直接回傳
                    if(User==null){
                        return builder.success().build();
                    }
                    ClcikStatistic clcikStatistic = new ClcikStatistic();
                    clcikStatistic.setUsername(user.getUsername());
                    clcikStatistic.setShareCode(uid);
                    clcikStatistic.setClickTime(1);
                    clcikStatistic.setCreateTime(new Date());
                    clcikStatistic.setLastClickTime(new Date());
                    redisTool.put(SHARE_CODE_CLICK, uid, clcikStatistic);
                    log.info("Ip :{} 在 {}訪問了 會員 {} 的投資頁面,訪問數+1" ,ip,DateUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss"),user.getUsername());
                }
            }
        }
        log.info("Ip:{} 於 {} 訪問 {}",ip,DateUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss"),uid);
			  return ResponseEntity.ok().build();
    }
Licensed under CC BY-NC-SA 4.0