TaiwanIDAddress 是一個用於處理臺灣地址格式化和身分證/居留證驗證的工具類別。此類別提供地址正規化、地址解析和驗證,以及身分證號碼和居留證號碼的驗證功能,特別適合需要處理臺灣地址和身分證資料的應用系統。
// 正規化地址字串
String rawAddress = "臺北市中山區 123號";
String normalized = TaiwanIDAddress.normalize(rawAddress);
// 輸出:臺北市中山區123號
// 驗證地址格式
String address = "臺北市中山區南京東路一段128號";
boolean isValid = TaiwanIDAddress.isValid(address);
// 輸出:true
// 解析地址各部分
String address = "106臺北市大安區仁愛路四段1號";
Map<String, String> parts = TaiwanIDAddress.parse(address);
// 輸出:
// postal: "106"
// city: "臺北市"
// district: "大安區"
// rest: "仁愛路四段1號"
// 驗證身分證號碼
String id = "A123456789";
boolean isValid = TaiwanIDAddress.isValidIDorRCNumber(id);
// 驗證居留證號碼
String rcNo = "AB12345678";
boolean isValidRC = TaiwanIDAddress.checkResidentID(rcNo);
// 使用自定義的 Pattern 處理特殊格式
Pattern customPattern = Pattern.compile("您的pattern");
Matcher matcher = customPattern.matcher(address);
if (matcher.matches()) {
String city = matcher.group("city");
// 處理解析結果
}
// 結合解析結果重新格式化地址
Map<String, String> parts = TaiwanIDAddress.parse(address);
String formatted = String.format("%s%s%s%s",
parts.get("postal") != null ? parts.get("postal") + " " : "",
parts.get("city"),
parts.get("district"),
parts.get("rest")
);
// 驗證身分證或居留證
if (TaiwanIDAddress.isValidIDorRCNumber(id)) {
// 驗證通過,進一步處理
if (id.charAt(1) == '1') {
System.out.println("男性身分證");
} else if (id.charAt(1) == '2') {
System.out.println("女性身分證");
} else if (id.charAt(1) >= '8') {
System.out.println("新式居留證");
}
}
## 單元測試範例
以下測試範例示範地址正規化、解析與身分證驗證:
### 1. 地址正規化與解析
```java
@Test
void testNormalizeAndParse() {
String raw = "臺北市中山區 123號";
String normalized = TaiwanIDAddress.normalize(raw);
assertEquals("臺北市中山區123號", normalized);
Map<String,String> parts = TaiwanIDAddress.parse("106臺北市大安區仁愛路四段1號");
assertEquals("106", parts.get("postal"));
assertEquals("臺北市", parts.get("city"));
}
@Test
void testIdValidation() {
assertTrue(TaiwanIDAddress.isValidIDorRCNumber("A123456789"));
assertFalse(TaiwanIDAddress.isValidIDorRCNumber("A12345678X"));
}
package tw.lewishome.webapp.base.utility.common;
import java.text.Normalizer;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
/**
* 臺灣地址正規化與簡易檢核工具
*
* 功能: - normalize: 將輸入地址做基本正規化(全形轉半形、統一「臺->臺」、整理空白與連字符等) - isValid:
* 依簡單規則判斷是否為合理的臺灣地址(可擴充) - parse: 嘗試抽出 郵遞區號、縣市、鄉鎮區 與其餘地址部分
*
* 注意:本類提供的是實用的輔助方法,不保證涵蓋所有例外與語意正確性。若需要精準分解(多音字、行政區最新變更),建議搭配官方行政區資料或專門的地址解析庫。
*/
public class TaiwanIDAddress {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
* Constructs a new TaiwanIDAddress instance.
* This is the default constructor, implicitly provided by the compiler
* and can be used to create a new instance of the class.
*
*/
public TaiwanIDAddress() {
// Default constructor
}
// 常用道路/門牌關鍵字,用於簡易檢核
private static final String[] ROAD_KEYWORDS = { "路", "街", "巷", "弄", "號", "段", "樓", "樓層" };
// 抽出郵遞區號、縣市、鄉鎮區、其餘地址的簡單正則
private static final Pattern PARSE_PATTERN = Pattern.compile(
"^\\s*(?<postal>\\d{3,5})?\\s*(?<city>[^\\s\\d,,]{2,}?(?:縣|市))\\s*(?<district>[^\\s\\d,,]{1,}?(?:鄉|鎮|市|區))\\s*(?<rest>.*)$");
// 更寬鬆的整體檢核(至少要有縣市與鄉鎮區)
private static final Pattern BASIC_VALIDATE_PATTERN = Pattern.compile(".*(縣|市).*?(鄉|鎮|市|區).*");
/**
* 正規化地址字串: - trim - 將全形字元轉半形(含數字、英文字母與空白) - 消除多重空白、統一破折號 - 將「臺」替換為「臺」 - Unicode
* 正規化 (NFKC) 以降低異形字影響
*
* @param rawAddress 原始地址
* @return 正規化後的地址
*/
public static String normalize(String rawAddress) {
if (rawAddress == null)
return null;
// Unicode 正規化,先合併組合字元等
String s = Normalizer.normalize(rawAddress, Normalizer.Form.NFKC);
// 全形->半形轉換(數字、英文字母、部分符號、空白)
s = toHalfWidth(s);
// 統一臺->臺
s = s.replace('臺', '臺');
// 統一各種長短破折號為 "-"
s = s.replace('-', '-').replace('—', '-').replace('–', '-').replace('〜', '~');
// 移除多餘的標點前後空白,以及重複空白
s = s.replaceAll("\\s*[,,:\\:]+\\s*", " ").replaceAll("\\s+", "").trim();
return s;
}
/**
* 簡單檢核地址是否看起來像臺灣地址 規則(可視需求擴充): - 需包含「縣/市」與「鄉/鎮/市/區」 - 或者整個字串符合常見路/街/巷/弄/號等關鍵字
*
* @param address 任意地址字串(會在檢核前做 normalize)
* @return 若通過簡易檢核回傳 true;否則 false
*/
public static boolean isValid(String address) {
if (address == null)
return false;
String s = normalize(address);
if (s.isEmpty())
return false;
// 必須包含縣市與鄉鎮區
Matcher m = BASIC_VALIDATE_PATTERN.matcher(s);
if (!m.find()) {
// 若沒有縣市+鄉鎮區,仍嘗試看是否至少包含門牌/路名等
for (String k : ROAD_KEYWORDS) {
if (s.contains(k))
return true;
}
return false;
}
// 若包含縣市與鄉鎮區,進一步確認是否有路名或門牌(若無也視為較弱但仍可接受)
for (String k : ROAD_KEYWORDS) {
if (s.contains(k))
return true;
}
// 沒有路關鍵字也算基本通過(例如僅縣市+區+裏?視情境可嚴格化)
return true;
}
/**
* 嘗試解析地址成 基本組成:postal, city, district, rest
*
* @param address 任意地址字串(會在解析前做 normalize)
* @return Map (ordered):若無對應欄位則為 null 或空字串;無法解析則回傳空的 Map
*/
public static Map<String, String> parse(String address) {
Map<String, String> result = new LinkedHashMap<>();
if (address == null)
return result;
String s = normalize(address);
Matcher m = PARSE_PATTERN.matcher(s);
if (m.matches()) {
String postal = nullSafeTrim(m.group("postal"));
String city = nullSafeTrim(m.group("city"));
String district = nullSafeTrim(m.group("district"));
String rest = nullSafeTrim(m.group("rest"));
if (postal != null && postal.isEmpty())
postal = null;
result.put("postal", postal);
result.put("city", city);
result.put("district", district);
result.put("rest", rest);
}
return result;
}
// ----- Helper methods -----
private static String nullSafeTrim(String s) {
return s == null ? null : s.trim();
}
/**
* 將可能的全形字元轉為半形。 針對數字、英文字母、部分符號與全形空白做轉換。
*/
private static String toHalfWidth(String input) {
if (input == null || input.isEmpty())
return input;
StringBuilder sb = new StringBuilder(input.length());
for (char ch : input.toCharArray()) {
// 全形空白 U+3000 -> half-width space
if (ch == '\u3000') {
sb.append(' ');
continue;
}
// 全形數字與字母範圍
if (ch >= '\uFF01' && ch <= '\uFF5E') {
// 轉回 ASCII 範圍
char converted = (char) (ch - 0xFEE0);
sb.append(converted);
continue;
}
// 常見全形符號單獨處理
switch (ch) {
case ',':
sb.append(',');
break;
case '。':
sb.append('.');
break;
case ':':
sb.append(':');
break;
case ';':
sb.append(';');
break;
case '(':
sb.append('(');
break;
case ')':
sb.append(')');
break;
case '「':
sb.append('\"');
break;
case '」':
sb.append('\"');
break;
case '『':
sb.append('\"');
break;
case '』':
sb.append('\"');
break;
default:
sb.append(ch);
}
}
return sb.toString();
}
/*
* 依據內政部: 1. 身分證編碼原則 2. 外來人口統一證號編碼原則(居留證)
* https://www.immigration.gov.tw/ct_cert.asp?xItem=1106801&ctNode=32601&mp=1
* 身分證及居留證通用 第一碼 縣市編碼原則: A=10 臺北市 J=18 新竹縣 S=26 高雄縣 B=11 臺中市 K=19 苗栗縣 T=27 屏東縣
* C=12 基隆市 L=20 臺中縣 U=28 花蓮縣 D=13 臺南市 M=21 南投縣 V=29 臺東縣 E=14 高雄市 N=22 彰化縣 W=32
* 金門縣 F=15 臺北縣 O=35 新竹市 X=30 澎湖縣 G=16 宜蘭縣 P=23 雲林縣 Y=31 陽明山 H=17 桃園縣 Q=24 嘉義縣
* Z=33 連江縣 I=34 嘉義市 R=25 臺南縣
*
* 第二碼 身分證: 1 男姓 2 女生 居留證: 臺灣地區無戶籍國民、大陸地區人民、港澳居民: A 男性 B 女性 外國人: C 男性 D 女性
*
* 第三碼 身分證: 目前均為 0 居留證: 1 男性 2 女性
*
* 其餘 4~9 碼均為數字 0...9
*
* 最末碼 檢核碼 檢核碼為根據前九碼編碼加權後之計算產生,用以核對 (checksum) 字號正確性 檢核碼意義可以參考
* http://finalfrank.pixnet.net/blog/post/19639058-身分證字號驗證方法
*
* 身分證字號產生器 https://people.debian.org/~paulliu/ROCid.html,可用以檢核程式是否正確
* 居留證號碼也是利用類似的概念,祇是第二碼英文字依第一碼原則取對照數字,祇取個位數,也就是 A 取 0, B 取 1, C 取 2, D 取 3
*
*
* @param str a String object
*
* @return a boolean
*/
/**
* 檢查臺灣身分證或居留證號碼是否合法
*
* @param str 身分證或居留證號碼
* @return boolean 是否合法
*/
public static boolean isValidIDorRCNumber(String str) {
if (StringUtils.isBlank(str)) {
return false;
}
final char[] pidCharArray = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z' };
// 原身分證英文字應轉換為10~33,這裡直接作個位數*9+10
final int[] pidIDInt = { 1, 10, 19, 28, 37, 46, 55, 64, 39, 73, 82, 2, 11, 20, 48, 29, 38, 47, 56, 65, 74, 83,
21, 3, 12, 30 };
// 原居留證第一碼英文字應轉換為10~33,十位數*1,個位數*9,這裡直接作[(十位數*1) mod 10] + [(個位數*9) mod 10]
final int[] pidResidentFirstInt = { 1, 10, 9, 8, 7, 6, 5, 4, 9, 3, 2, 2, 11, 10, 8, 9, 8, 7, 6, 5, 4, 3, 11, 3,
12, 10 };
// 原居留證第二碼英文字應轉換為10~33,並僅取個位數*8,這裡直接取[(個位數*8) mod 10]
final int[] pidResidentSecondInt = { 0, 8, 6, 4, 2, 0, 8, 6, 2, 4, 2, 0, 8, 6, 0, 4, 2, 0, 8, 6, 4, 2, 6, 0, 8,
4 };
str = str.toUpperCase();// 轉換大寫
final char[] strArr = str.toCharArray();// 字串轉成char陣列
int verifyNum = 0;
/* 檢查身分證字號 */
if (str.matches("[A-Z]{1}[1-2]{1}[0-9]{8}")) {
// 第一碼
verifyNum = verifyNum + pidIDInt[Arrays.binarySearch(pidCharArray, strArr[0])];
// 第二~九碼
for (int i = 1, j = 8; i < 9; i++, j--) {
verifyNum += Character.digit(strArr[i], 10) * j;
}
// 檢查碼
verifyNum = (10 - (verifyNum % 10)) % 10;
return verifyNum == Character.digit(strArr[9], 10);
}
/* 檢查統一證(居留證)編號 (2031/1/1停用) */
verifyNum = 0;
if (str.matches("[A-Z]{1}[A-D]{1}[0-9]{8}")) {
// 第一碼
verifyNum += pidResidentFirstInt[Arrays.binarySearch(pidCharArray, strArr[0])];
// 第二碼
verifyNum += pidResidentSecondInt[Arrays.binarySearch(pidCharArray, strArr[1])];
// 第三~八碼
for (int i = 2, j = 7; i < 9; i++, j--) {
verifyNum += Character.digit(strArr[i], 10) * j;
}
// 檢查碼
verifyNum = (10 - (verifyNum % 10)) % 10;
return verifyNum == Character.digit(strArr[9], 10);
}
/* 檢查統一證(居留證)編號 (2021/1/2實施) */
verifyNum = 0;
if (str.matches("[A-Z]{1}[89]{1}[0-9]{8}")) {
// 第一碼
verifyNum += pidResidentFirstInt[Arrays.binarySearch(pidCharArray, strArr[0])];
// 第二~八碼
for (int i = 1, j = 8; i < 9; i++, j--) {
verifyNum += Character.digit(strArr[i], 10) * j;
}
// 檢查碼
verifyNum = (10 - (verifyNum % 10)) % 10;
return verifyNum == Character.digit(strArr[9], 10);
}
return false;
}
/**
* 檢查臺灣居留證號碼是否合法(支援舊式與新式)
*
* @param idNo 居留證號碼
* @return boolean 是否合法
*/
public static boolean checkResidentID(String idNo) {
if (StringUtils.isBlank(idNo)) {
return false;
}
idNo = idNo.toUpperCase();
// 舊式: 第一碼英文,第二碼 A~D,後8碼數字
// 新式: 第一碼英文,第二碼 8或9,後8碼數字
if (!idNo.matches("^[A-Z][A-D89][0-9]{8}$")) {
return false;
}
String alphabet = "ABCDEFGHJKLMNPQRSTUVXYWZIO";
char firstLetter = idNo.charAt(0);
char secondLetter = idNo.charAt(1);
String num = idNo.substring(2);
if ("ABCD".indexOf(secondLetter) >= 0) {
// 舊式
int firstCode = alphabet.indexOf(firstLetter) + 10;
int secondCode = (alphabet.indexOf(secondLetter) + 10) % 10;
String transferIdNo = String.valueOf(firstCode) + secondCode + num;
int[] idNoArray = new int[transferIdNo.length()];
for (int i = 0; i < transferIdNo.length(); i++) {
idNoArray[i] = Character.getNumericValue(transferIdNo.charAt(i));
}
int sum = idNoArray[0];
int[] weight = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 1 };
for (int i = 0; i < weight.length; i++) {
sum += weight[i] * idNoArray[i + 1];
}
return sum % 10 == 0;
} else {
// 新式
int firstCode = alphabet.indexOf(firstLetter) + 10;
String transferIdNo = String.valueOf(firstCode) + idNo.substring(1);
int[] idNoArray = new int[transferIdNo.length()];
for (int i = 0; i < transferIdNo.length(); i++) {
idNoArray[i] = Character.getNumericValue(transferIdNo.charAt(i));
}
int sum = idNoArray[0];
int[] weight = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 1 };
for (int i = 0; i < weight.length; i++) {
sum += (weight[i] * idNoArray[i + 1]) % 10;
}
return sum % 10 == 0;
}
}
}
package tw.lewishome.webapp.base.utility.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class TaiwanIDAddressTest {
@Test
public void testNormalize() {
assertNull(TaiwanIDAddress.normalize(null));
assertEquals("", TaiwanIDAddress.normalize(""));
assertEquals("台北市信義區", TaiwanIDAddress.normalize("台北市信義區"));
assertEquals("台北市信義區信義路5段", TaiwanIDAddress.normalize("台北市信義區信義路5段"));
assertEquals("台北市信義區信義路5段7號", TaiwanIDAddress.normalize("台北市,信義區,信義路5段7號"));
}
@Test
public void testIsValid() {
assertFalse(TaiwanIDAddress.isValid(null));
assertFalse(TaiwanIDAddress.isValid(""));
assertFalse(TaiwanIDAddress.isValid("abc123"));
assertTrue(TaiwanIDAddress.isValid("台北市信義區"));
assertTrue(TaiwanIDAddress.isValid("新北市板橋區中山路"));
assertTrue(TaiwanIDAddress.isValid("台中市西屯區文心路二段"));
}
@Test
public void testParse() {
Map<String, String> result = TaiwanIDAddress.parse("40669 台中市北屯區文心路四段955號");
assertEquals("40669", result.get("postal"));
assertEquals("台中市", result.get("city"));
assertEquals("北屯區", result.get("district"));
assertEquals("文心路四段955號", result.get("rest"));
result = TaiwanIDAddress.parse("台北市信義區信義路五段7號");
assertNull(result.get("postal"));
assertEquals("台北市", result.get("city"));
assertEquals("信義區", result.get("district"));
assertEquals("信義路五段7號", result.get("rest"));
result = TaiwanIDAddress.parse(null);
assertTrue(result.isEmpty());
result = TaiwanIDAddress.parse("invalid address");
assertTrue(result.isEmpty());
}
@Test
@DisplayName("isValidIDorRCNumber: test valid and invalid IDs")
void testIsValidIDorRCNumber() {
// Valid Taiwan ID
assertTrue(TaiwanIDAddress.isValidIDorRCNumber("A123456789"));
// Invalid Taiwan ID
assertFalse(TaiwanIDAddress.isValidIDorRCNumber("A123456788"));
// Valid old RC number
assertTrue(TaiwanIDAddress.isValidIDorRCNumber("AB12345677"));
// Invalid old RC number
assertFalse(TaiwanIDAddress.isValidIDorRCNumber("AB12345679"));
// Valid new RC number
assertTrue(TaiwanIDAddress.isValidIDorRCNumber("A812345671"));
// Invalid new RC number
assertFalse(TaiwanIDAddress.isValidIDorRCNumber("A812345679"));
// Null and empty
assertFalse(TaiwanIDAddress.isValidIDorRCNumber(null));
assertFalse(TaiwanIDAddress.isValidIDorRCNumber(""));
// Invalid format
assertFalse(TaiwanIDAddress.isValidIDorRCNumber("1234567890"));
}
@Test
@DisplayName("checkResidentID: validate resident ID numbers")
void testCheckResidentID() {
// Test old format resident IDs (with A-D as second character)
assertTrue(TaiwanIDAddress.checkResidentID("AC12345679"));
assertFalse(TaiwanIDAddress.checkResidentID("AD12345670")); // Invalid checksum
// Test new format resident IDs (with 8-9 as second character)
assertTrue(TaiwanIDAddress.checkResidentID("A800000014"));
assertFalse(TaiwanIDAddress.checkResidentID("A912345670")); // Invalid checksum
// Test invalid formats
assertFalse(TaiwanIDAddress.checkResidentID(null));
assertFalse(TaiwanIDAddress.checkResidentID(""));
assertFalse(TaiwanIDAddress.checkResidentID("123456789")); // No letters
assertFalse(TaiwanIDAddress.checkResidentID("AE12345678")); // Invalid second character
assertFalse(TaiwanIDAddress.checkResidentID("A1234567")); // Too short
assertFalse(TaiwanIDAddress.checkResidentID("AB123456789")); // Too long
assertFalse(TaiwanIDAddress.checkResidentID("A912A45678")); // Invalid number format
}
}